+
+
\ No newline at end of file
diff --git a/apk/controller/.idea/gradle.xml b/apk/controller/.idea/gradle.xml
new file mode 100644
index 0000000..639c779
--- /dev/null
+++ b/apk/controller/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/.idea/markdown.xml b/apk/controller/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/apk/controller/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/.idea/migrations.xml b/apk/controller/.idea/migrations.xml
new file mode 100644
index 0000000..f8051a6
--- /dev/null
+++ b/apk/controller/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/.idea/misc.xml b/apk/controller/.idea/misc.xml
new file mode 100644
index 0000000..b2c751a
--- /dev/null
+++ b/apk/controller/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/.idea/runConfigurations.xml b/apk/controller/.idea/runConfigurations.xml
new file mode 100644
index 0000000..16660f1
--- /dev/null
+++ b/apk/controller/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/.idea/studiobot.xml b/apk/controller/.idea/studiobot.xml
new file mode 100644
index 0000000..539e3b8
--- /dev/null
+++ b/apk/controller/.idea/studiobot.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/.idea/vcs.xml b/apk/controller/.idea/vcs.xml
new file mode 100644
index 0000000..0f4e7ba
--- /dev/null
+++ b/apk/controller/.idea/vcs.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/LICENSE b/apk/controller/LICENSE
new file mode 100644
index 0000000..5c5357c
--- /dev/null
+++ b/apk/controller/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2023 hev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apk/controller/README.md b/apk/controller/README.md
new file mode 100644
index 0000000..46b2b89
--- /dev/null
+++ b/apk/controller/README.md
@@ -0,0 +1,38 @@
+# IIAB-oA Controller
+
+**IIAB-oA Controller** is a specialized infrastructure component for the **Internet-in-a-Box (IIAB)** ecosystem on Android. It acts as a "Walled Garden" and persistent "Watchdog" designed to keep the Termux environment alive and accessible, even on devices with aggressive power management (e.g., Oppo/ColorOS, MIUI).
+
+## Core Capabilities
+
+### 🛡️ Master Watchdog (Supervision Layer)
+An independent foreground service dedicated to environment stability:
+
+* **CPU & Wi-Fi Shield**: Prevents the system from putting Termux into Doze mode or disabling the Wi-Fi radio.
+* **Heartbeat Pulse**: Sends a regulated API signal every 20 seconds to maintain process priority.
+* **Zero-Config Protection**: Works independently of the VPN tunnel.
+
+### 🌐 Safe Pocket Web (Network Layer)
+A high-performance VPN tunnel based on the tun2socks engine:
+
+* **Friendly URLs**: Routes traffic through internal IIAB services seamlessly.
+* **Walled Garden**: Ensures a secure, filtered browsing environment.
+* **Per-App Routing**: Granular control over which applications use the secure tunnel.
+
+### 🔒 Built-in Security
+* **Biometric/PIN Lock**: Authentication is strictly required before the Watchdog or VPN can be disabled.
+* **Safety Check**: Prevents activation if the device lacks a secure lock method (PIN/Pattern/Fingerprint), ensuring the user is never "locked out" of their own settings.
+
+## Acknowledgments
+This project is a heavily customized spin-off of **[SocksTun](https://github.com/heiher/sockstun)** created by **[heiher](https://github.com/heiher)**.
+All credit for the core native tunneling engine goes to the original author. This derivative has been re-architected to meet the specific requirements of the IIAB project.
+
+## Technical Details
+* **Current Version**: v0.1.12alpha
+* **License**: **MIT License** (See [LICENSE](LICENSE) for details).
+* **Compatibility**: Android 8.0 (API 26) and above.
+
+## Disclaimer
+This is a preview and demo published in the hope that it will be useful, but WITHOUT ANY WARRANTY.
+
+---
+*Maintained by IIAB Contributors - 2026*
diff --git a/apk/controller/app/build.gradle b/apk/controller/app/build.gradle
new file mode 100644
index 0000000..5610bce
--- /dev/null
+++ b/apk/controller/app/build.gradle
@@ -0,0 +1,80 @@
+apply plugin: 'com.android.application'
+
+android {
+ namespace "org.iiab.controller"
+ compileSdkVersion 34
+ ndkVersion "26.3.11579264"
+
+ defaultConfig {
+ applicationId "org.iiab.controller"
+ minSdkVersion 24
+ targetSdkVersion 34
+ versionCode 21
+ versionName "v0.1.25alpha-debug"
+ setProperty("archivesBaseName", "$applicationId-$versionName")
+ ndk {
+ abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
+ }
+ externalNativeBuild {
+ ndkBuild {
+ arguments "APP_CFLAGS+=-DPKGNAME=org/iiab/controller -DCLSNAME=TProxyService -ffile-prefix-map=${rootDir}=."
+ arguments "APP_LDFLAGS+=-Wl,--build-id=none"
+ }
+ }
+ }
+
+ signingConfigs {
+ release {
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ signingConfig signingConfigs.release
+ }
+ debug {
+ minifyEnabled false
+ signingConfig signingConfigs.release
+ }
+ }
+
+ def propsFile = rootProject.file('store.properties')
+ def configName = 'release'
+
+ if (propsFile.exists() && android.signingConfigs.hasProperty(configName)) {
+ def props = new Properties()
+ props.load(new FileInputStream(propsFile))
+ if (props!=null && props.containsKey('storeFile')) {
+ android.signingConfigs[configName].storeFile = rootProject.file(props['storeFile'])
+ android.signingConfigs[configName].storePassword = props['storePassword']
+ android.signingConfigs[configName].keyAlias = props['keyAlias']
+ android.signingConfigs[configName].keyPassword = props['keyPassword']
+ }
+ }
+
+ externalNativeBuild {
+ ndkBuild {
+ path "src/main/jni/Android.mk"
+ }
+ }
+
+ lintOptions {
+ checkReleaseBuilds false
+ // Or, if you prefer, you can continue to check for errors in release builds,
+ // but continue the build even when errors are found:
+ abortOnError false
+ }
+
+ dependenciesInfo {
+ // Disables dependency metadata when building APKs.
+ includeInApk = false
+ // Disables dependency metadata when building Android App Bundles.
+ includeInBundle = false
+ }
+}
+
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.4.1'
+ implementation 'androidx.biometric:biometric:1.1.0'
+}
diff --git a/apk/controller/app/release/org.iiab.controller-v0.1.14alpha-release.apk b/apk/controller/app/release/org.iiab.controller-v0.1.14alpha-release.apk
new file mode 100644
index 0000000..2fe06da
Binary files /dev/null and b/apk/controller/app/release/org.iiab.controller-v0.1.14alpha-release.apk differ
diff --git a/apk/controller/app/release/output-metadata.json b/apk/controller/app/release/output-metadata.json
new file mode 100644
index 0000000..fed0783
--- /dev/null
+++ b/apk/controller/app/release/output-metadata.json
@@ -0,0 +1,21 @@
+{
+ "version": 3,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "org.iiab.controller",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "attributes": [],
+ "versionCode": 8,
+ "versionName": "v0.1.14alpha",
+ "outputFile": "org.iiab.controller-v0.1.14alpha-release.apk"
+ }
+ ],
+ "elementType": "File",
+ "minSdkVersionForDexing": 24
+}
\ No newline at end of file
diff --git a/apk/controller/app/src/main/AndroidManifest.xml b/apk/controller/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a8a1a7b
--- /dev/null
+++ b/apk/controller/app/src/main/AndroidManifest.xml
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apk/controller/app/src/main/ic_launcher-playstore.png b/apk/controller/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..823e64e
Binary files /dev/null and b/apk/controller/app/src/main/ic_launcher-playstore.png differ
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/AppListActivity.java b/apk/controller/app/src/main/java/org/iiab/controller/AppListActivity.java
new file mode 100644
index 0000000..0df17d4
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/AppListActivity.java
@@ -0,0 +1,225 @@
+/*
+ ============================================================================
+ Name : AppListActivity.java
+ Author : hev
+ Copyright : Copyright (c) 2025 xyz
+ Description : App List Activity
+ ============================================================================
+ */
+
+package org.iiab.controller;
+
+import java.util.Set;
+import java.util.List;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Comparator;
+import java.util.Collections;
+import java.util.ArrayList;
+
+import android.Manifest;
+import android.os.Bundle;
+import android.app.ListActivity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.LayoutInflater;
+import android.widget.ArrayAdapter;
+import android.widget.CheckBox;
+import android.widget.ImageView;
+import android.widget.ListView;
+import android.widget.TextView;
+import android.widget.EditText;
+import android.text.TextWatcher;
+import android.text.Editable;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageInfo;
+import android.content.pm.ApplicationInfo;
+
+public class AppListActivity extends ListActivity {
+ private Preferences prefs;
+ private AppArrayAdapter adapter;
+ private boolean isChanged = false;
+
+ private class Package {
+ public PackageInfo info;
+ public boolean selected;
+ public String label;
+
+ public Package(PackageInfo info, boolean selected, String label) {
+ this.info = info;
+ this.selected = selected;
+ this.label = label;
+ }
+ }
+
+ private class AppArrayAdapter extends ArrayAdapter {
+ private final List allPackages = new ArrayList();
+ private final List filteredPackages = new ArrayList();
+ private String lastFilter = "";
+
+ public AppArrayAdapter(Context context) {
+ super(context, R.layout.appitem);
+ }
+
+ @Override
+ public void add(Package pkg) {
+ allPackages.add(pkg);
+ if (matchesFilter(pkg, lastFilter))
+ filteredPackages.add(pkg);
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void clear() {
+ allPackages.clear();
+ filteredPackages.clear();
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public void sort(Comparator super Package> cmp) {
+ Collections.sort(allPackages, (Comparator) cmp);
+ applyFilter(lastFilter);
+ }
+
+ @Override
+ public int getCount() {
+ return filteredPackages.size();
+ }
+
+ @Override
+ public Package getItem(int position) {
+ return filteredPackages.get(position);
+ }
+
+ public List getAllPackages() {
+ return allPackages;
+ }
+
+ private boolean matchesFilter(Package pkg, String filter) {
+ if (filter == null || filter.length() == 0)
+ return true;
+ return pkg.label.toLowerCase().contains(filter.toLowerCase());
+ }
+
+ public void applyFilter(String filter) {
+ lastFilter = filter != null ? filter : "";
+ filteredPackages.clear();
+ if (lastFilter.length() == 0) {
+ filteredPackages.addAll(allPackages);
+ } else {
+ String f = lastFilter.toLowerCase();
+ for (Package p : allPackages) {
+ if (p.label != null && p.label.toLowerCase().contains(f))
+ filteredPackages.add(p);
+ }
+ }
+ notifyDataSetChanged();
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ LayoutInflater inflater = (LayoutInflater) getContext()
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ View rowView = inflater.inflate(R.layout.appitem, parent, false);
+ ImageView imageView = (ImageView) rowView.findViewById(R.id.icon);
+ TextView textView = (TextView) rowView.findViewById(R.id.name);
+ CheckBox checkBox = (CheckBox) rowView.findViewById(R.id.checked);
+
+ Package pkg = getItem(position);
+ PackageManager pm = getContext().getPackageManager();
+ ApplicationInfo appinfo = pkg.info.applicationInfo;
+ imageView.setImageDrawable(appinfo.loadIcon(pm));
+ textView.setText(appinfo.loadLabel(pm).toString());
+ checkBox.setChecked(pkg.selected);
+
+ return rowView;
+ }
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ getListView().setChoiceMode(ListView.CHOICE_MODE_MULTIPLE);
+
+ prefs = new Preferences(this);
+ Set apps = prefs.getApps();
+ PackageManager pm = getPackageManager();
+ adapter = new AppArrayAdapter(this);
+
+ for (PackageInfo info : pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)) {
+ if (info.packageName.equals(getPackageName()))
+ continue;
+ if (info.requestedPermissions == null)
+ continue;
+ if (!Arrays.asList(info.requestedPermissions).contains(Manifest.permission.INTERNET))
+ continue;
+ boolean selected = apps.contains(info.packageName);
+ String label = info.applicationInfo.loadLabel(pm).toString();
+ Package pkg = new Package(info, selected, label);
+ adapter.add(pkg);
+ }
+
+ EditText searchBox = new EditText(this);
+ searchBox.setHint("Search");
+ int pad = (int) (8 * getResources().getDisplayMetrics().density);
+ searchBox.setPadding(pad, pad, pad, pad);
+ getListView().addHeaderView(searchBox, null, false);
+
+ adapter.sort(new Comparator() {
+ public int compare(Package a, Package b) {
+ if (a.selected != b.selected)
+ return a.selected ? -1 : 1;
+ return a.label.compareTo(b.label);
+ }
+ });
+
+ setListAdapter(adapter);
+
+ searchBox.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ adapter.applyFilter(s.toString());
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) { }
+ });
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (isChanged) {
+ Set apps = new HashSet();
+
+ for (Package pkg : adapter.getAllPackages()) {
+ if (pkg.selected)
+ apps.add(pkg.info.packageName);
+ }
+
+ prefs.setApps(apps);
+ }
+
+ super.onDestroy();
+ }
+
+ @Override
+ protected void onListItemClick(ListView l, View v, int position, long id) {
+ int headers = l.getHeaderViewsCount();
+ int adjPos = position - headers;
+ if (adjPos < 0)
+ return;
+ Package pkg = adapter.getItem(adjPos);
+ pkg.selected = !pkg.selected;
+ CheckBox checkbox = (CheckBox) v.findViewById(R.id.checked);
+ if (checkbox != null)
+ checkbox.setChecked(pkg.selected);
+ isChanged = true;
+ }
+}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/IIABWatchdog.java b/apk/controller/app/src/main/java/org/iiab/controller/IIABWatchdog.java
new file mode 100644
index 0000000..e5fd896
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/IIABWatchdog.java
@@ -0,0 +1,122 @@
+package org.iiab.controller;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * A stateless utility class to perform keep-alive actions for Termux.
+ * The lifecycle (start/stop/loop) is managed by the calling service.
+ */
+public class IIABWatchdog {
+ private static final String TAG = "IIAB-Controller";
+
+ public static final String ACTION_LOG_MESSAGE = "org.iiab.controller.LOG_MESSAGE";
+ public static final String EXTRA_MESSAGE = "org.iiab.controller.EXTRA_MESSAGE";
+
+ // --- TEMPORARY DEBUG FLAGS ---
+ private static final boolean DEBUG_ENABLED = true;
+ private static final String BLACKBOX_FILE = "watchdog_heartbeat_log.txt";
+ // ----------------------------
+
+ /**
+ * Performs a full heartbeat pulse: sending stimulus and debug ping.
+ */
+ public static void performHeartbeat(Context context) {
+ sendStimulus(context);
+ performDebugPing(context);
+ }
+
+ /**
+ * Sends a command to Termux to keep it active.
+ * This is the real keep-alive mechanism.
+ * @param context The context to use for sending the intent.
+ */
+ public static void sendStimulus(Context context) {
+ if (DEBUG_ENABLED) {
+ writeToBlackBox(context, "Sending Intent (true) to Termux to keep it awake...");
+ }
+
+ Intent intent = new Intent("com.termux.service.RUN_COMMAND");
+ intent.setClassName("com.termux", "com.termux.app.RunCommandService");
+ intent.putExtra("com.termux.service.RUN_COMMAND_PATH", "/data/data/com.termux/files/usr/bin/true");
+ intent.putExtra("com.termux.service.RUN_COMMAND_BACKGROUND", true);
+
+ try {
+ context.startService(intent);
+ } catch (Exception e) {
+ if (DEBUG_ENABLED) {
+ Log.e(TAG, "[DEBUG_DEEP_SLEEP] Failed to send 'true' command to Termux", e);
+ writeToBlackBox(context, "ERROR sending Intent: " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * Pings the Termux NGINX server to check if it's responsive.
+ * This is a temporary debugging tool.
+ * // TODO: REMOVE AFTER HANS DEBUGGING
+ * @param context The context for writing to the blackbox log.
+ */
+ public static void performDebugPing(Context context) {
+ final String NGINX_IP = "127.0.0.1";
+ final int NGINX_PORT = 8085;
+
+ new Thread(() -> {
+ try (Socket socket = new Socket()) {
+ socket.connect(new InetSocketAddress(NGINX_IP, NGINX_PORT), 2000);
+ if (DEBUG_ENABLED) {
+ Log.e(TAG, "[DEBUG_DEEP_SLEEP] PING 8085 SUCCESSFUL: Termux is alive.");
+ writeToBlackBox(context, "PING 8085 SUCCESSFUL: Termux is alive.");
+ }
+ } catch (IOException e) {
+ if (DEBUG_ENABLED) {
+ Log.w(TAG, "[DEBUG_DEEP_SLEEP] PING 8085 FAILED: " + e.getMessage());
+ writeToBlackBox(context, "PING 8085 FAILED: " + e.getMessage());
+ }
+ }
+ }).start();
+ }
+
+ public static void logSessionStart(Context context) {
+ if (DEBUG_ENABLED) {
+ writeToBlackBox(context, "HEARTBEAT SESSION STARTED (Thread based)");
+ }
+ }
+
+ public static void logSessionStop(Context context) {
+ if (DEBUG_ENABLED) {
+ writeToBlackBox(context, "HEARTBEAT SESSION STOPPED");
+ }
+ }
+
+ private static void writeToBlackBox(Context context, String message) {
+ try {
+ File logFile = new File(context.getFilesDir(), BLACKBOX_FILE);
+ FileWriter writer = new FileWriter(logFile, true);
+ String datePrefix = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(new Date());
+ writer.append(datePrefix).append(" - ").append(message).append("\n");
+ writer.close();
+
+ // Also broadcast for UI update
+ broadcastLog(context, message);
+ } catch (IOException e) {
+ Log.e(TAG, "Failed to write to BlackBox", e);
+ }
+ }
+
+ private static void broadcastLog(Context context, String message) {
+ Intent intent = new Intent(ACTION_LOG_MESSAGE);
+ intent.putExtra(EXTRA_MESSAGE, message);
+ context.sendBroadcast(intent);
+ }
+}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/MainActivity.java b/apk/controller/app/src/main/java/org/iiab/controller/MainActivity.java
new file mode 100644
index 0000000..d6ef819
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/MainActivity.java
@@ -0,0 +1,540 @@
+/*
+ ============================================================================
+ Name : MainActivity.java
+ Author : hev
+ Copyright : Copyright (c) 2023 xyz
+ Description : Main Activity
+ ============================================================================
+ */
+
+package org.iiab.controller;
+
+import android.os.Bundle;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.app.AppCompatDelegate;
+import androidx.appcompat.app.AlertDialog;
+import android.content.Intent;
+import android.content.Context;
+import android.content.IntentFilter;
+import android.content.BroadcastReceiver;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.SharedPreferences;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.util.Log;
+import android.view.View;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+import android.net.VpnService;
+import android.net.Uri;
+import android.text.method.ScrollingMovementMethod;
+import android.os.Build;
+import android.os.PowerManager;
+import android.provider.Settings;
+
+import androidx.annotation.NonNull;
+import androidx.biometric.BiometricManager;
+import androidx.biometric.BiometricPrompt;
+import androidx.core.content.ContextCompat;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.concurrent.Executor;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+
+public class MainActivity extends AppCompatActivity implements View.OnClickListener {
+ private Preferences prefs;
+ private EditText edittext_socks_addr;
+ private EditText edittext_socks_udp_addr;
+ private EditText edittext_socks_port;
+ private EditText edittext_socks_user;
+ private EditText edittext_socks_pass;
+ private EditText edittext_dns_ipv4;
+ private EditText edittext_dns_ipv6;
+ private CheckBox checkbox_udp_in_tcp;
+ private CheckBox checkbox_remote_dns;
+ private CheckBox checkbox_global;
+ private CheckBox checkbox_ipv4;
+ private CheckBox checkbox_ipv6;
+ private Button button_apps;
+ private Button button_save;
+ private Button button_control;
+
+ private Button watchdogControl;
+ private TextView connectionLog;
+ private LinearLayout logActions;
+ private Button btnClearLog;
+ private Button btnCopyLog;
+ private LinearLayout configLayout;
+ private TextView configLabel;
+ private LinearLayout advancedConfig;
+ private TextView advConfigLabel;
+ private TextView logLabel;
+ private ImageButton themeToggle;
+ private TextView versionFooter;
+
+ private BroadcastReceiver logReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (IIABWatchdog.ACTION_LOG_MESSAGE.equals(intent.getAction())) {
+ String message = intent.getStringExtra(IIABWatchdog.EXTRA_MESSAGE);
+ addToLog(message);
+ }
+ }
+ };
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ prefs = new Preferences(this);
+ setContentView(R.layout.main);
+
+ edittext_socks_addr = (EditText) findViewById(R.id.socks_addr);
+ edittext_socks_udp_addr = (EditText) findViewById(R.id.socks_udp_addr);
+ edittext_socks_port = (EditText) findViewById(R.id.socks_port);
+ edittext_socks_user = (EditText) findViewById(R.id.socks_user);
+ edittext_socks_pass = (EditText) findViewById(R.id.socks_pass);
+ edittext_dns_ipv4 = (EditText) findViewById(R.id.dns_ipv4);
+ edittext_dns_ipv6 = (EditText) findViewById(R.id.dns_ipv6);
+ checkbox_ipv4 = (CheckBox) findViewById(R.id.ipv4);
+ checkbox_ipv6 = (CheckBox) findViewById(R.id.ipv6);
+ checkbox_global = (CheckBox) findViewById(R.id.global);
+ checkbox_udp_in_tcp = (CheckBox) findViewById(R.id.udp_in_tcp);
+ checkbox_remote_dns = (CheckBox) findViewById(R.id.remote_dns);
+ button_apps = (Button) findViewById(R.id.apps);
+ button_save = (Button) findViewById(R.id.save);
+ button_control = (Button) findViewById(R.id.control);
+
+ watchdogControl = (Button) findViewById(R.id.watchdog_control);
+ watchdogControl.setOnClickListener(this);
+
+ logActions = (LinearLayout) findViewById(R.id.log_actions);
+ btnClearLog = (Button) findViewById(R.id.btn_clear_log);
+ btnCopyLog = (Button) findViewById(R.id.btn_copy_log);
+ btnClearLog.setOnClickListener(this);
+ btnCopyLog.setOnClickListener(this);
+
+ connectionLog = (TextView) findViewById(R.id.connection_log);
+ connectionLog.setMovementMethod(new ScrollingMovementMethod());
+ // Enable text selection for copying large logs
+ connectionLog.setTextIsSelectable(true);
+
+ configLayout = (LinearLayout) findViewById(R.id.config_layout);
+ configLabel = (TextView) findViewById(R.id.config_label);
+ configLabel.setOnClickListener(v -> toggleVisibility(configLayout, configLabel, "Configuration"));
+
+ advancedConfig = (LinearLayout) findViewById(R.id.advanced_config);
+ advConfigLabel = (TextView) findViewById(R.id.adv_config_label);
+ advConfigLabel.setOnClickListener(v -> toggleVisibility(advancedConfig, advConfigLabel, "Advanced Settings"));
+
+ logLabel = (TextView) findViewById(R.id.log_label);
+ logLabel.setOnClickListener(v -> {
+ if (connectionLog.getVisibility() == View.GONE) {
+ readBlackBoxLogs(); // Load logs from file when expanding
+ }
+ toggleVisibility(connectionLog, logLabel, "Connection Log");
+ logActions.setVisibility(connectionLog.getVisibility());
+ });
+
+ themeToggle = (ImageButton) findViewById(R.id.theme_toggle);
+ themeToggle.setOnClickListener(v -> toggleTheme());
+ applySavedTheme();
+
+ versionFooter = (TextView) findViewById(R.id.version_text);
+ setVersionFooter();
+
+ checkbox_udp_in_tcp.setOnClickListener(this);
+ checkbox_remote_dns.setOnClickListener(this);
+ checkbox_global.setOnClickListener(this);
+ button_apps.setOnClickListener(this);
+ button_save.setOnClickListener(this);
+ button_control.setOnClickListener(this);
+ updateUI();
+
+ /* Request VPN permission */
+ Intent intent = VpnService.prepare(MainActivity.this);
+ if (intent != null)
+ startActivityForResult(intent, 0);
+ else
+ onActivityResult(0, RESULT_OK, null);
+
+ checkBatteryOptimizations();
+ addToLog("Application Started");
+ }
+
+ private void readBlackBoxLogs() {
+ File logFile = new File(getFilesDir(), "watchdog_heartbeat_log.txt");
+ if (!logFile.exists()) {
+ addToLog("--- No BlackBox file found ---");
+ return;
+ }
+
+ addToLog("--- Loading BlackBox Logs ---");
+ try (BufferedReader br = new BufferedReader(new FileReader(logFile))) {
+ String line;
+ while ((line = br.readLine()) != null) {
+ addToLog("[FILE] " + line);
+ }
+ } catch (IOException e) {
+ addToLog("Error reading BlackBox: " + e.getMessage());
+ }
+ addToLog("--- End of File ---");
+ }
+
+ private void setVersionFooter() {
+ try {
+ PackageInfo pInfo = getPackageManager().getPackageInfo(getPackageName(), 0);
+ String version = pInfo.versionName;
+ versionFooter.setText(version);
+ } catch (PackageManager.NameNotFoundException e) {
+ versionFooter.setText("v0.1.x");
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (getIntent() != null && getIntent().getBooleanExtra(VpnRecoveryReceiver.EXTRA_RECOVERY, false)) {
+ addToLog("Recovery Pulse Received from System. Enforcing VPN...");
+ Intent vpnIntent = new Intent(this, TProxyService.class);
+ startService(vpnIntent.setAction(TProxyService.ACTION_CONNECT));
+ setIntent(null);
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ }
+
+ private void checkBatteryOptimizations() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
+ if (pm != null && !pm.isIgnoringBatteryOptimizations(getPackageName())) {
+ new AlertDialog.Builder(this)
+ .setTitle("Battery Optimization")
+ .setMessage("For the Watchdog to work reliably, please disable battery optimizations for this app.")
+ .setPositiveButton("Go to Settings", (dialog, which) -> {
+ Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+ intent.setData(Uri.parse("package:" + getPackageName()));
+ startActivity(intent);
+ })
+ .setNegativeButton("Cancel", null)
+ .show();
+ }
+ }
+ }
+
+ private void toggleTheme() {
+ SharedPreferences sharedPref = getPreferences(Context.MODE_PRIVATE);
+ int currentMode = AppCompatDelegate.getDefaultNightMode();
+ int nextMode;
+
+ if (currentMode == AppCompatDelegate.MODE_NIGHT_NO) {
+ nextMode = AppCompatDelegate.MODE_NIGHT_YES;
+ } else if (currentMode == AppCompatDelegate.MODE_NIGHT_YES) {
+ nextMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM;
+ } else {
+ nextMode = AppCompatDelegate.MODE_NIGHT_NO;
+ }
+
+ SharedPreferences.Editor editor = sharedPref.edit();
+ editor.putInt("ui_mode", nextMode);
+ editor.apply();
+
+ AppCompatDelegate.setDefaultNightMode(nextMode);
+ updateThemeToggleButton(nextMode);
+ }
+
+ private void applySavedTheme() {
+ SharedPreferences sharedPref = getPreferences(Context.MODE_PRIVATE);
+ int savedMode = sharedPref.getInt("ui_mode", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
+ AppCompatDelegate.setDefaultNightMode(savedMode);
+ updateThemeToggleButton(savedMode);
+ }
+
+ private void updateThemeToggleButton(int mode) {
+ if (mode == AppCompatDelegate.MODE_NIGHT_NO) {
+ themeToggle.setImageResource(R.drawable.ic_theme_dark);
+ } else if (mode == AppCompatDelegate.MODE_NIGHT_YES) {
+ themeToggle.setImageResource(R.drawable.ic_theme_light);
+ } else {
+ themeToggle.setImageResource(R.drawable.ic_theme_system);
+ }
+ }
+
+ private void toggleVisibility(View view, TextView label, String text) {
+ if (view.getVisibility() == View.GONE) {
+ view.setVisibility(View.VISIBLE);
+ label.setText("▼ " + text);
+ } else {
+ view.setVisibility(View.GONE);
+ label.setText("▶ " + text);
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ IntentFilter filter = new IntentFilter(IIABWatchdog.ACTION_LOG_MESSAGE);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(logReceiver, filter, Context.RECEIVER_EXPORTED);
+ } else {
+ registerReceiver(logReceiver, filter);
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ super.onStop();
+ try {
+ unregisterReceiver(logReceiver);
+ } catch (Exception e) {
+ // Ignore
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int request, int result, Intent data) {
+ super.onActivityResult(request, result, data);
+ if ((result == RESULT_OK) && prefs.getEnable()) {
+ Intent intent = new Intent(this, TProxyService.class);
+ startService(intent.setAction(TProxyService.ACTION_CONNECT));
+ addToLog("VPN Permission Granted. Connecting...");
+ }
+ }
+
+ @Override
+ public void onClick(View view) {
+ if (view == checkbox_global || view == checkbox_remote_dns) {
+ savePrefs();
+ updateUI();
+ } else if (view == button_apps) {
+ startActivity(new Intent(this, AppListActivity.class));
+ } else if (view == button_save) {
+ savePrefs();
+ Context context = getApplicationContext();
+ Toast.makeText(context, "Saved", Toast.LENGTH_SHORT).show();
+ addToLog("Settings Saved");
+ } else if (view.getId() == R.id.control) {
+ handleControlClick();
+ } else if (view.getId() == R.id.watchdog_control) {
+ handleWatchdogClick();
+ } else if (view.getId() == R.id.btn_clear_log) {
+ connectionLog.setText("");
+ addToLog("Log reset");
+ } else if (view.getId() == R.id.btn_copy_log) {
+ ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
+ ClipData clip = ClipData.newPlainText("IIAB Log", connectionLog.getText().toString());
+ if (clipboard != null) {
+ clipboard.setPrimaryClip(clip);
+ Toast.makeText(this, "Log copied to clipboard", Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
+
+ private void handleWatchdogClick() {
+ boolean isEnabled = prefs.getWatchdogEnable();
+ if (isEnabled) {
+ showWatchdogBiometricPrompt();
+ } else {
+ toggleWatchdog(false);
+ }
+ }
+
+ private void toggleWatchdog(boolean stop) {
+ prefs.setWatchdogEnable(!stop);
+ Intent intent = new Intent(this, WatchdogService.class);
+ if (stop) {
+ stopService(intent);
+ addToLog("Watchdog Stopping...");
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ startForegroundService(intent.setAction(WatchdogService.ACTION_START));
+ } else {
+ startService(intent.setAction(WatchdogService.ACTION_START));
+ }
+ addToLog("Watchdog Starting...");
+ }
+ updateUI();
+ }
+
+ private void handleControlClick() {
+ boolean isCurrentlyEnabled = prefs.getEnable();
+ if (isCurrentlyEnabled) {
+ showBiometricPrompt();
+ } else {
+ BiometricManager biometricManager = BiometricManager.from(this);
+ int authenticators = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) ?
+ (BiometricManager.Authenticators.BIOMETRIC_STRONG | BiometricManager.Authenticators.DEVICE_CREDENTIAL) :
+ BiometricManager.Authenticators.BIOMETRIC_WEAK;
+
+ boolean isSecure = false;
+ android.app.KeyguardManager km = (android.app.KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
+ if (km != null && km.isDeviceSecure()) isSecure = true;
+
+ if (biometricManager.canAuthenticate(authenticators) == BiometricManager.BIOMETRIC_SUCCESS || isSecure) {
+ addToLog("User initiated connection");
+ toggleService(false);
+ } else {
+ showEnrollmentDialog();
+ }
+ }
+ }
+
+ private void showEnrollmentDialog() {
+ new AlertDialog.Builder(this)
+ .setTitle("Security Required")
+ .setMessage("You must set up a PIN, Pattern, or Fingerprint on your device before enabling the secure environment.")
+ .setPositiveButton("Go to Settings", (dialog, which) -> {
+ Intent intent = new Intent(Settings.ACTION_SECURITY_SETTINGS);
+ startActivity(intent);
+ })
+ .setNegativeButton("Cancel", null)
+ .show();
+ }
+
+ private void showBiometricPrompt() {
+ Executor executor = ContextCompat.getMainExecutor(this);
+ BiometricPrompt biometricPrompt = new BiometricPrompt(MainActivity.this,
+ executor, new BiometricPrompt.AuthenticationCallback() {
+ @Override
+ public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
+ super.onAuthenticationSucceeded(result);
+ addToLog("Authentication Success. Disconnecting...");
+ toggleService(true);
+ }
+ });
+
+ BiometricPrompt.PromptInfo.Builder promptBuilder = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Authentication required")
+ .setSubtitle("Authenticate to disable the secure environment");
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ promptBuilder.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG |
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL);
+ } else {
+ promptBuilder.setDeviceCredentialAllowed(true);
+ }
+ biometricPrompt.authenticate(promptBuilder.build());
+ }
+
+ private void showWatchdogBiometricPrompt() {
+ Executor executor = ContextCompat.getMainExecutor(this);
+ BiometricPrompt biometricPrompt = new BiometricPrompt(this, executor, new BiometricPrompt.AuthenticationCallback() {
+ @Override
+ public void onAuthenticationSucceeded(@NonNull BiometricPrompt.AuthenticationResult result) {
+ super.onAuthenticationSucceeded(result);
+ toggleWatchdog(true);
+ }
+ });
+
+ BiometricPrompt.PromptInfo.Builder promptBuilder = new BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Unlock Master Watchdog")
+ .setSubtitle("Authentication required to stop Termux protection");
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ promptBuilder.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG |
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL);
+ } else {
+ promptBuilder.setDeviceCredentialAllowed(true);
+ }
+ biometricPrompt.authenticate(promptBuilder.build());
+ }
+
+ private void toggleService(boolean isEnable) {
+ prefs.setEnable(!isEnable);
+ savePrefs();
+ updateUI();
+ Intent intent = new Intent(this, TProxyService.class);
+ if (isEnable) {
+ startService(intent.setAction(TProxyService.ACTION_DISCONNECT));
+ addToLog("VPN Stopping...");
+ } else {
+ startService(intent.setAction(TProxyService.ACTION_CONNECT));
+ addToLog("VPN Starting...");
+ }
+ }
+
+ private void updateUI() {
+ boolean vpnActive = prefs.getEnable();
+ boolean watchdogActive = prefs.getWatchdogEnable();
+
+ if (vpnActive) {
+ button_control.setText(R.string.control_disable);
+ button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_on));
+ } else {
+ button_control.setText(R.string.control_enable);
+ button_control.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_vpn_off));
+ }
+
+ if (watchdogActive) {
+ watchdogControl.setText("Disable Master Watchdog");
+ watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_on));
+ } else {
+ watchdogControl.setText("Enable Master Watchdog");
+ watchdogControl.setBackgroundTintList(ContextCompat.getColorStateList(this, R.color.btn_watchdog_off));
+ }
+
+ edittext_socks_addr.setText(prefs.getSocksAddress());
+ edittext_socks_udp_addr.setText(prefs.getSocksUdpAddress());
+ edittext_socks_port.setText(String.valueOf(prefs.getSocksPort()));
+ edittext_socks_user.setText(prefs.getSocksUsername());
+ edittext_socks_pass.setText(prefs.getSocksPassword());
+ edittext_dns_ipv4.setText(prefs.getDnsIpv4());
+ edittext_dns_ipv6.setText(prefs.getDnsIpv6());
+ checkbox_ipv4.setChecked(prefs.getIpv4());
+ checkbox_ipv6.setChecked(prefs.getIpv6());
+ checkbox_global.setChecked(prefs.getGlobal());
+ checkbox_udp_in_tcp.setChecked(prefs.getUdpInTcp());
+ checkbox_remote_dns.setChecked(prefs.getRemoteDns());
+
+ boolean editable = !vpnActive;
+ edittext_socks_addr.setEnabled(editable);
+ edittext_socks_port.setEnabled(editable);
+ button_save.setEnabled(editable);
+ }
+
+ private void savePrefs() {
+ prefs.setSocksAddress(edittext_socks_addr.getText().toString());
+ prefs.setSocksPort(Integer.parseInt(edittext_socks_port.getText().toString()));
+ prefs.setSocksUdpAddress(edittext_socks_udp_addr.getText().toString());
+ prefs.setSocksUsername(edittext_socks_user.getText().toString());
+ prefs.setSocksPassword(edittext_socks_pass.getText().toString());
+ prefs.setDnsIpv4(edittext_dns_ipv4.getText().toString());
+ prefs.setDnsIpv6(edittext_dns_ipv6.getText().toString());
+ prefs.setIpv4(checkbox_ipv4.isChecked());
+ prefs.setIpv6(checkbox_ipv6.isChecked());
+ prefs.setGlobal(checkbox_global.isChecked());
+ prefs.setUdpInTcp(checkbox_udp_in_tcp.isChecked());
+ prefs.setRemoteDns(checkbox_remote_dns.isChecked());
+ }
+
+ private void addToLog(String message) {
+ runOnUiThread(() -> {
+ SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
+ String currentTime = sdf.format(new Date());
+ String logEntry = "[" + currentTime + "] " + message + "\n";
+ if (connectionLog != null) {
+ connectionLog.append(logEntry);
+ // Automatic scrolling to bottom
+ final int scrollAmount = connectionLog.getLayout() != null ?
+ connectionLog.getLayout().getLineTop(connectionLog.getLineCount()) - connectionLog.getHeight() : 0;
+ if (scrollAmount > 0)
+ connectionLog.scrollTo(0, scrollAmount);
+ }
+ });
+ }
+}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/Preferences.java b/apk/controller/app/src/main/java/org/iiab/controller/Preferences.java
new file mode 100644
index 0000000..db563ed
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/Preferences.java
@@ -0,0 +1,219 @@
+/*
+ ============================================================================
+ Name : Preferences.java
+ Author : hev
+ Copyright : Copyright (c) 2023 xyz
+ Description : Preferences
+ ============================================================================
+ */
+
+package org.iiab.controller;
+
+import java.util.Set;
+import java.util.HashSet;
+import android.content.Context;
+import android.content.SharedPreferences;
+
+public class Preferences
+{
+ public static final String PREFS_NAME = "SocksPrefs";
+ public static final String SOCKS_ADDR = "SocksAddr";
+ public static final String SOCKS_UDP_ADDR = "SocksUdpAddr";
+ public static final String SOCKS_PORT = "SocksPort";
+ public static final String SOCKS_USER = "SocksUser";
+ public static final String SOCKS_PASS = "SocksPass";
+ public static final String DNS_IPV4 = "DnsIpv4";
+ public static final String DNS_IPV6 = "DnsIpv6";
+ public static final String IPV4 = "Ipv4";
+ public static final String IPV6 = "Ipv6";
+ public static final String GLOBAL = "Global";
+ public static final String UDP_IN_TCP = "UdpInTcp";
+ public static final String REMOTE_DNS = "RemoteDNS";
+ public static final String APPS = "Apps";
+ public static final String ENABLE = "Enable";
+ public static final String WATCHDOG_ENABLE = "WatchdogEnable";
+
+ private SharedPreferences prefs;
+
+ public Preferences(Context context) {
+ prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_MULTI_PROCESS);
+ }
+
+ public String getSocksAddress() {
+ return prefs.getString(SOCKS_ADDR, "127.0.0.1");
+ }
+
+ public void setSocksAddress(String addr) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(SOCKS_ADDR, addr);
+ editor.commit();
+ }
+
+ public String getSocksUdpAddress() {
+ return prefs.getString(SOCKS_UDP_ADDR, "");
+ }
+
+ public void setSocksUdpAddress(String addr) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(SOCKS_UDP_ADDR, addr);
+ editor.commit();
+ }
+
+ public int getSocksPort() {
+ return prefs.getInt(SOCKS_PORT, 1080);
+ }
+
+ public void setSocksPort(int port) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putInt(SOCKS_PORT, port);
+ editor.commit();
+ }
+
+ public String getSocksUsername() {
+ return prefs.getString(SOCKS_USER, "");
+ }
+
+ public void setSocksUsername(String user) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(SOCKS_USER, user);
+ editor.commit();
+ }
+
+ public String getSocksPassword() {
+ return prefs.getString(SOCKS_PASS, "");
+ }
+
+ public void setSocksPassword(String pass) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(SOCKS_PASS, pass);
+ editor.commit();
+ }
+
+ public String getDnsIpv4() {
+ return prefs.getString(DNS_IPV4, "8.8.8.8");
+ }
+
+ public void setDnsIpv4(String addr) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(DNS_IPV4, addr);
+ editor.commit();
+ }
+
+ public String getDnsIpv6() {
+ return prefs.getString(DNS_IPV6, "2001:4860:4860::8888");
+ }
+
+ public void setDnsIpv6(String addr) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putString(DNS_IPV6, addr);
+ editor.commit();
+ }
+
+ public String getMappedDns() {
+ return "198.18.0.2";
+ }
+
+ public boolean getUdpInTcp() {
+ return prefs.getBoolean(UDP_IN_TCP, false);
+ }
+
+ public void setUdpInTcp(boolean enable) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(UDP_IN_TCP, enable);
+ editor.commit();
+ }
+
+ public boolean getRemoteDns() {
+ return prefs.getBoolean(REMOTE_DNS, true);
+ }
+
+ public void setRemoteDns(boolean enable) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(REMOTE_DNS, enable);
+ editor.commit();
+ }
+
+ public boolean getIpv4() {
+ return prefs.getBoolean(IPV4, true);
+ }
+
+ public void setIpv4(boolean enable) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(IPV4, enable);
+ editor.commit();
+ }
+
+ public boolean getIpv6() {
+ return prefs.getBoolean(IPV6, true);
+ }
+
+ public void setIpv6(boolean enable) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(IPV6, enable);
+ editor.commit();
+ }
+
+ public boolean getGlobal() {
+ return prefs.getBoolean(GLOBAL, false);
+ }
+
+ public void setGlobal(boolean enable) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(GLOBAL, enable);
+ editor.commit();
+ }
+
+ public Set getApps() {
+ return prefs.getStringSet(APPS, new HashSet());
+ }
+
+ public void setApps(Set apps) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putStringSet(APPS, apps);
+ editor.commit();
+ }
+
+ public boolean getEnable() {
+ return prefs.getBoolean(ENABLE, false);
+ }
+
+ public void setEnable(boolean enable) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(ENABLE, enable);
+ editor.commit();
+ }
+
+ public boolean getWatchdogEnable() {
+ return prefs.getBoolean(WATCHDOG_ENABLE, false);
+ }
+
+ public void setWatchdogEnable(boolean enable) {
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(WATCHDOG_ENABLE, enable);
+ editor.commit();
+ }
+
+ public int getTunnelMtu() {
+ return 8500;
+ }
+
+ public String getTunnelIpv4Address() {
+ return "198.18.0.1";
+ }
+
+ public int getTunnelIpv4Prefix() {
+ return 32;
+ }
+
+ public String getTunnelIpv6Address() {
+ return "fc00::1";
+ }
+
+ public int getTunnelIpv6Prefix() {
+ return 128;
+ }
+
+ public int getTaskStackSize() {
+ return 81920;
+ }
+}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/ServiceReceiver.java b/apk/controller/app/src/main/java/org/iiab/controller/ServiceReceiver.java
new file mode 100644
index 0000000..a81e052
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/ServiceReceiver.java
@@ -0,0 +1,40 @@
+/*
+ ============================================================================
+ Name : ServiceReceiver.java
+ Author : hev
+ Copyright : Copyright (c) 2023 xyz
+ Description : ServiceReceiver
+ ============================================================================
+ */
+
+package org.iiab.controller;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.VpnService;
+import android.os.Build;
+
+public class ServiceReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) {
+ Preferences prefs = new Preferences(context);
+
+ /* Auto-start */
+ if (prefs.getEnable()) {
+ Intent i = VpnService.prepare(context);
+ if (i != null) {
+ i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(i);
+ }
+ i = new Intent(context, TProxyService.class);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(i.setAction(TProxyService.ACTION_CONNECT));
+ } else {
+ context.startService(i.setAction(TProxyService.ACTION_CONNECT));
+ }
+ }
+ }
+ }
+}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/TProxyService.java b/apk/controller/app/src/main/java/org/iiab/controller/TProxyService.java
new file mode 100644
index 0000000..397123e
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/TProxyService.java
@@ -0,0 +1,337 @@
+/*
+ ============================================================================
+ Name : TProxyService.java
+ Author : hev
+ Copyright : Copyright (c) 2024 xyz
+ Description : TProxy Service
+ ============================================================================
+ */
+
+package org.iiab.controller;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Context;
+import android.os.Build;
+import android.os.ParcelFileDescriptor;
+import android.os.PowerManager;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.net.VpnService;
+import android.net.wifi.WifiManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ServiceInfo;
+import android.util.Log;
+
+import androidx.core.app.NotificationCompat;
+
+public class TProxyService extends VpnService {
+ private static final String TAG = "IIAB-TProxy";
+ private static native void TProxyStartService(String config_path, int fd);
+ private static native void TProxyStopService();
+ private static native long[] TProxyGetStats();
+
+ public static final String ACTION_CONNECT = "org.iiab.controller.CONNECT";
+ public static final String ACTION_DISCONNECT = "org.iiab.controller.DISCONNECT";
+ public static final String ACTION_WATCHDOG_SYNC = "org.iiab.controller.WATCHDOG_SYNC";
+
+ private PowerManager.WakeLock wakeLock;
+ private WifiManager.WifiLock wifiLock;
+
+ private Thread watchdogThread;
+ private volatile boolean isWatchdogRunning = false;
+
+ static {
+ System.loadLibrary("hev-socks5-tunnel");
+ }
+
+ private ParcelFileDescriptor tunFd = null;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent != null) {
+ String action = intent.getAction();
+ if (ACTION_DISCONNECT.equals(action)) {
+ stopService();
+ return START_NOT_STICKY;
+ } else if (ACTION_WATCHDOG_SYNC.equals(action)) {
+ syncWatchdogLocks();
+ return START_STICKY;
+ }
+ }
+ startService();
+ return START_STICKY;
+ }
+
+ private void syncWatchdogLocks() {
+ Preferences prefs = new Preferences(this);
+ boolean watchdogEnabled = prefs.getWatchdogEnable();
+ Log.d(TAG, "Syncing Watchdog state. Enabled: " + watchdogEnabled);
+
+ if (watchdogEnabled) {
+ acquireLocks();
+ startWatchdogLoop();
+ } else {
+ stopWatchdogLoop();
+ releaseLocks();
+ }
+ }
+
+ private void startWatchdogLoop() {
+ if (isWatchdogRunning) return;
+
+ isWatchdogRunning = true;
+ IIABWatchdog.logSessionStart(this);
+
+ watchdogThread = new Thread(() -> {
+ Log.i(TAG, "Watchdog Thread: Started loop");
+ while (isWatchdogRunning) {
+ try {
+ // Perform the stimulus (The real keep-alive)
+ IIABWatchdog.sendStimulus(this);
+
+ // Perform the debug ping (Checking if Termux is responsive)
+ IIABWatchdog.performDebugPing(this);
+
+ // Sleep for 30 seconds
+ Thread.sleep(30000);
+ } catch (InterruptedException e) {
+ Log.i(TAG, "Watchdog Thread: Interrupted, stopping...");
+ break;
+ } catch (Exception e) {
+ Log.e(TAG, "Watchdog Thread: Error in loop", e);
+ }
+ }
+ Log.i(TAG, "Watchdog Thread: Loop ended");
+ });
+ watchdogThread.setName("IIAB-Watchdog-Thread");
+ watchdogThread.start();
+ }
+
+ private void stopWatchdogLoop() {
+ isWatchdogRunning = false;
+ if (watchdogThread != null) {
+ watchdogThread.interrupt();
+ watchdogThread = null;
+ }
+ IIABWatchdog.logSessionStop(this);
+ }
+
+ private void acquireLocks() {
+ try {
+ if (wakeLock == null) {
+ PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
+ wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "IIAB:TProxyWakeLock");
+ wakeLock.acquire();
+ Log.i(TAG, "CPU WakeLock acquired under VPN shield");
+ }
+ if (wifiLock == null) {
+ WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "IIAB:TProxyWifiLock");
+ wifiLock.acquire();
+ Log.i(TAG, "Wi-Fi Lock acquired under VPN shield");
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error acquiring locks", e);
+ }
+ }
+
+ private void releaseLocks() {
+ if (wakeLock != null && wakeLock.isHeld()) {
+ wakeLock.release();
+ wakeLock = null;
+ Log.i(TAG, "CPU WakeLock released");
+ }
+ if (wifiLock != null && wifiLock.isHeld()) {
+ wifiLock.release();
+ wifiLock = null;
+ Log.i(TAG, "Wi-Fi Lock released");
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ stopWatchdogLoop();
+ releaseLocks();
+ super.onDestroy();
+ }
+
+ @Override
+ public void onRevoke() {
+ super.onRevoke();
+ }
+
+ public void startService() {
+ if (tunFd != null) {
+ syncWatchdogLocks();
+ return;
+ }
+
+ Preferences prefs = new Preferences(this);
+
+ /* VPN */
+ String session = new String();
+ VpnService.Builder builder = new VpnService.Builder();
+ builder.setBlocking(false);
+ builder.setMtu(prefs.getTunnelMtu());
+ if (prefs.getIpv4()) {
+ String addr = prefs.getTunnelIpv4Address();
+ int prefix = prefs.getTunnelIpv4Prefix();
+ String dns = prefs.getDnsIpv4();
+ builder.addAddress(addr, prefix);
+ builder.addRoute("0.0.0.0", 0);
+ if (!prefs.getRemoteDns() && !dns.isEmpty())
+ builder.addDnsServer(dns);
+ session += "IPv4";
+ }
+ if (prefs.getIpv6()) {
+ String addr = prefs.getTunnelIpv6Address();
+ int prefix = prefs.getTunnelIpv6Prefix();
+ String dns = prefs.getDnsIpv6();
+ builder.addAddress(addr, prefix);
+ builder.addRoute("::", 0);
+ if (!prefs.getRemoteDns() && !dns.isEmpty())
+ builder.addDnsServer(dns);
+ if (!session.isEmpty())
+ session += " + ";
+ session += "IPv6";
+ }
+ if (prefs.getRemoteDns()) {
+ builder.addDnsServer(prefs.getMappedDns());
+ }
+ boolean disallowSelf = true;
+ if (prefs.getGlobal()) {
+ session += "/Global";
+ } else {
+ for (String appName : prefs.getApps()) {
+ try {
+ builder.addAllowedApplication(appName);
+ disallowSelf = false;
+ } catch (NameNotFoundException e) {
+ }
+ }
+ session += "/per-App";
+ }
+ if (disallowSelf) {
+ String selfName = getApplicationContext().getPackageName();
+ try {
+ builder.addDisallowedApplication(selfName);
+ } catch (NameNotFoundException e) {
+ }
+ }
+ builder.setSession(session);
+ tunFd = builder.establish();
+ if (tunFd == null) {
+ stopSelf();
+ return;
+ }
+
+ /* TProxy */
+ File tproxy_file = new File(getCacheDir(), "tproxy.conf");
+ try {
+ tproxy_file.createNewFile();
+ FileOutputStream fos = new FileOutputStream(tproxy_file, false);
+
+ String tproxy_conf = "misc:\n" +
+ " task-stack-size: " + prefs.getTaskStackSize() + "\n" +
+ "tunnel:\n" +
+ " mtu: " + prefs.getTunnelMtu() + "\n";
+
+ tproxy_conf += "socks5:\n" +
+ " port: " + prefs.getSocksPort() + "\n" +
+ " address: '" + prefs.getSocksAddress() + "'\n" +
+ " udp: '" + (prefs.getUdpInTcp() ? "tcp" : "udp") + "'\n";
+
+ if (!prefs.getSocksUdpAddress().isEmpty()) {
+ tproxy_conf += " udp-address: '" + prefs.getSocksUdpAddress() + "'\n";
+ }
+
+ if (!prefs.getSocksUsername().isEmpty() &&
+ !prefs.getSocksPassword().isEmpty()) {
+ tproxy_conf += " username: '" + prefs.getSocksUsername() + "'\n";
+ tproxy_conf += " password: '" + prefs.getSocksPassword() + "'\n";
+ }
+
+ if (prefs.getRemoteDns()) {
+ tproxy_conf += "mapdns:\n" +
+ " address: " + prefs.getMappedDns() + "\n" +
+ " port: 53\n" +
+ " network: 240.0.0.0\n" +
+ " netmask: 240.0.0.0\n" +
+ " cache-size: 10000\n";
+ }
+
+ fos.write(tproxy_conf.getBytes());
+ fos.close();
+ } catch (IOException e) {
+ return;
+ }
+ TProxyStartService(tproxy_file.getAbsolutePath(), tunFd.getFd());
+ prefs.setEnable(true);
+
+ String channelName = "socks5";
+ initNotificationChannel(channelName);
+ createNotification(channelName);
+
+ // Start loop and locks if enabled
+ syncWatchdogLocks();
+ }
+
+ public void stopService() {
+ if (tunFd == null)
+ return;
+
+ stopWatchdogLoop();
+ releaseLocks();
+ stopForeground(true);
+
+ /* TProxy */
+ TProxyStopService();
+
+ /* VPN */
+ try {
+ tunFd.close();
+ } catch (IOException e) {
+ }
+ tunFd = null;
+
+ System.exit(0);
+ }
+
+ private void createNotification(String channelName) {
+ Intent i = new Intent(this, MainActivity.class);
+ i.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+ PendingIntent pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_IMMUTABLE);
+ NotificationCompat.Builder notification = new NotificationCompat.Builder(this, channelName);
+ Notification notify = notification
+ .setContentTitle(getString(R.string.app_name))
+ .setSmallIcon(android.R.drawable.sym_def_app_icon)
+ .setContentIntent(pi)
+ .build();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(1, notify);
+ } else {
+ startForeground(1, notify, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
+ }
+ }
+
+ // create NotificationChannel
+ private void initNotificationChannel(String channelName) {
+ NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ CharSequence name = getString(R.string.app_name);
+ NotificationChannel channel = new NotificationChannel(channelName, name, NotificationManager.IMPORTANCE_DEFAULT);
+ notificationManager.createNotificationChannel(channel);
+ }
+ }
+}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/VpnRecoveryReceiver.java b/apk/controller/app/src/main/java/org/iiab/controller/VpnRecoveryReceiver.java
new file mode 100644
index 0000000..709db7e
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/VpnRecoveryReceiver.java
@@ -0,0 +1,68 @@
+package org.iiab.controller;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.util.Log;
+import androidx.core.app.NotificationCompat;
+
+public class VpnRecoveryReceiver extends BroadcastReceiver {
+ private static final String TAG = "IIAB-VpnRecovery";
+ public static final String EXTRA_RECOVERY = "recovery_mode";
+ private static final String CHANNEL_ID = "recovery_channel";
+ private static final int NOTIFICATION_ID = 911;
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if ("org.iiab.controller.RECOVER_VPN".equals(intent.getAction())) {
+ Log.i(TAG, "Boomerang Signal Received! Triggering high-priority recovery...");
+
+ Preferences prefs = new Preferences(context);
+ if (prefs.getEnable()) {
+ showRecoveryNotification(context);
+ }
+ }
+ }
+
+ private void showRecoveryNotification(Context context) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID, "VPN Recovery",
+ NotificationManager.IMPORTANCE_HIGH
+ );
+ channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC);
+ if (manager != null) manager.createNotificationChannel(channel);
+ }
+
+ Intent uiIntent = new Intent(context, MainActivity.class);
+ uiIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ uiIntent.putExtra(EXTRA_RECOVERY, true);
+
+ PendingIntent pendingIntent = PendingIntent.getActivity(
+ context, 0, uiIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ );
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(android.R.drawable.ic_dialog_alert)
+ .setContentTitle("Safe Pocket Web Interrupted")
+ .setContentText("Tap to restore secure environment immediately.")
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setCategory(NotificationCompat.CATEGORY_ALARM)
+ .setAutoCancel(true)
+ .setOngoing(true)
+ .setFullScreenIntent(pendingIntent, true) // High priority request to open
+ .setContentIntent(pendingIntent);
+
+ if (manager != null) {
+ manager.notify(NOTIFICATION_ID, builder.build());
+ }
+ }
+}
diff --git a/apk/controller/app/src/main/java/org/iiab/controller/WatchdogService.java b/apk/controller/app/src/main/java/org/iiab/controller/WatchdogService.java
new file mode 100644
index 0000000..12133e0
--- /dev/null
+++ b/apk/controller/app/src/main/java/org/iiab/controller/WatchdogService.java
@@ -0,0 +1,143 @@
+package org.iiab.controller;
+
+import android.app.AlarmManager;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.SystemClock;
+import android.util.Log;
+import androidx.core.app.NotificationCompat;
+
+public class WatchdogService extends Service {
+ private static final String CHANNEL_ID = "watchdog_channel";
+ private static final int NOTIFICATION_ID = 2;
+
+ public static final String ACTION_START = "org.iiab.controller.WATCHDOG_START";
+ public static final String ACTION_STOP = "org.iiab.controller.WATCHDOG_STOP";
+ public static final String ACTION_HEARTBEAT = "org.iiab.controller.HEARTBEAT";
+
+ private static final int HEARTBEAT_INTERVAL_MS = 20 * 1000;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ createNotificationChannel();
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ if (intent != null) {
+ String action = intent.getAction();
+ if (ACTION_START.equals(action)) {
+ startWatchdog();
+ } else if (ACTION_STOP.equals(action)) {
+ stopWatchdog();
+ return START_NOT_STICKY;
+ } else if (ACTION_HEARTBEAT.equals(action)) {
+ IIABWatchdog.performHeartbeat(this);
+ // CRITICAL: Reschedule for the next pulse to create an infinite loop
+ scheduleHeartbeat();
+ }
+ }
+ return START_STICKY;
+ }
+
+ private void startWatchdog() {
+ Notification notification = createNotification();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(NOTIFICATION_ID, notification);
+ } else {
+ startForeground(NOTIFICATION_ID, notification);
+ }
+
+ IIABWatchdog.logSessionStart(this);
+ scheduleHeartbeat();
+ }
+
+ private void stopWatchdog() {
+ cancelHeartbeat();
+ IIABWatchdog.logSessionStop(this);
+ stopForeground(true);
+ stopSelf();
+ }
+
+ private PendingIntent getHeartbeatPendingIntent() {
+ Intent intent = new Intent(this, WatchdogService.class);
+ intent.setAction(ACTION_HEARTBEAT);
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ flags |= PendingIntent.FLAG_IMMUTABLE;
+ }
+ return PendingIntent.getService(this, 0, intent, flags);
+ }
+
+ private void scheduleHeartbeat() {
+ AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ PendingIntent pendingIntent = getHeartbeatPendingIntent();
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ // This wakes up the device even in Doze Mode
+ alarmManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ SystemClock.elapsedRealtime() + HEARTBEAT_INTERVAL_MS,
+ pendingIntent);
+ } else {
+ alarmManager.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ SystemClock.elapsedRealtime() + HEARTBEAT_INTERVAL_MS,
+ pendingIntent);
+ }
+ }
+
+ private void cancelHeartbeat() {
+ AlarmManager alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
+ PendingIntent pendingIntent = getHeartbeatPendingIntent();
+ if (alarmManager != null) {
+ alarmManager.cancel(pendingIntent);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ stopWatchdog();
+ super.onDestroy();
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ private void createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ CHANNEL_ID, "IIAB Watchdog Service",
+ NotificationManager.IMPORTANCE_HIGH
+ );
+ channel.setDescription("Ensures services remain active when screen is off.");
+ NotificationManager manager = getSystemService(NotificationManager.class);
+ if (manager != null) {
+ manager.createNotificationChannel(channel);
+ }
+ }
+ }
+
+ private Notification createNotification() {
+ Intent notificationIntent = new Intent(this, MainActivity.class);
+ PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent,
+ PendingIntent.FLAG_IMMUTABLE);
+
+ return new NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("IIAB Watchdog Active")
+ .setContentText("Protecting Termux environment...")
+ .setSmallIcon(android.R.drawable.ic_lock_idle_lock)
+ .setContentIntent(pendingIntent)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setOngoing(true)
+ .build();
+ }
+}
diff --git a/apk/controller/app/src/main/jni/Android.mk b/apk/controller/app/src/main/jni/Android.mk
new file mode 100644
index 0000000..548efc9
--- /dev/null
+++ b/apk/controller/app/src/main/jni/Android.mk
@@ -0,0 +1,16 @@
+# Copyright (C) 2023 The Android Open Source Project
+#
+# 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.
+#
+
+include $(call all-subdir-makefiles)
diff --git a/apk/controller/app/src/main/jni/Application.mk b/apk/controller/app/src/main/jni/Application.mk
new file mode 100644
index 0000000..505d0f4
--- /dev/null
+++ b/apk/controller/app/src/main/jni/Application.mk
@@ -0,0 +1,21 @@
+# Copyright (C) 2023 The Android Open Source Project
+#
+# 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.
+#
+
+APP_OPTIM := release
+APP_PLATFORM := android-29
+APP_ABI := armeabi-v7a arm64-v8a
+APP_CFLAGS := -O3 -DPKGNAME=hev/sockstun
+APP_CPPFLAGS := -O3 -std=c++11
+NDK_TOOLCHAIN_VERSION := clang
diff --git a/apk/controller/app/src/main/jni/hev-socks5-tunnel b/apk/controller/app/src/main/jni/hev-socks5-tunnel
new file mode 160000
index 0000000..4d6c334
--- /dev/null
+++ b/apk/controller/app/src/main/jni/hev-socks5-tunnel
@@ -0,0 +1 @@
+Subproject commit 4d6c334dbfb68a79d1970c2744e62d09f71df12f
diff --git a/apk/controller/app/src/main/res/drawable/ic_launcher_background.xml b/apk/controller/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..ca3826a
--- /dev/null
+++ b/apk/controller/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apk/controller/app/src/main/res/drawable/ic_launcher_foreground.xml b/apk/controller/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..274f969
--- /dev/null
+++ b/apk/controller/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/apk/controller/app/src/main/res/drawable/ic_theme_dark.xml b/apk/controller/app/src/main/res/drawable/ic_theme_dark.xml
new file mode 100644
index 0000000..ef032cf
--- /dev/null
+++ b/apk/controller/app/src/main/res/drawable/ic_theme_dark.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/apk/controller/app/src/main/res/drawable/ic_theme_light.xml b/apk/controller/app/src/main/res/drawable/ic_theme_light.xml
new file mode 100644
index 0000000..e031529
--- /dev/null
+++ b/apk/controller/app/src/main/res/drawable/ic_theme_light.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/apk/controller/app/src/main/res/drawable/ic_theme_system.xml b/apk/controller/app/src/main/res/drawable/ic_theme_system.xml
new file mode 100644
index 0000000..9bee5e3
--- /dev/null
+++ b/apk/controller/app/src/main/res/drawable/ic_theme_system.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/apk/controller/app/src/main/res/drawable/rounded_button.xml b/apk/controller/app/src/main/res/drawable/rounded_button.xml
new file mode 100644
index 0000000..04b6403
--- /dev/null
+++ b/apk/controller/app/src/main/res/drawable/rounded_button.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/apk/controller/app/src/main/res/layout/appitem.xml b/apk/controller/app/src/main/res/layout/appitem.xml
new file mode 100644
index 0000000..06dcf42
--- /dev/null
+++ b/apk/controller/app/src/main/res/layout/appitem.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
diff --git a/apk/controller/app/src/main/res/layout/main.xml b/apk/controller/app/src/main/res/layout/main.xml
new file mode 100644
index 0000000..609fe1d
--- /dev/null
+++ b/apk/controller/app/src/main/res/layout/main.xml
@@ -0,0 +1,279 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apk/controller/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apk/controller/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..bbd3e02
--- /dev/null
+++ b/apk/controller/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/apk/controller/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..bbd3e02
--- /dev/null
+++ b/apk/controller/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/apk/controller/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/apk/controller/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..c6e0d8d
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/apk/controller/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..021b2b2
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-hdpi/sockstun.png b/apk/controller/app/src/main/res/mipmap-hdpi/sockstun.png
new file mode 100644
index 0000000..b901054
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-hdpi/sockstun.png differ
diff --git a/apk/controller/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/apk/controller/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..b7366b7
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/apk/controller/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..528b2ce
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-mdpi/sockstun.png b/apk/controller/app/src/main/res/mipmap-mdpi/sockstun.png
new file mode 100644
index 0000000..1da8c5e
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-mdpi/sockstun.png differ
diff --git a/apk/controller/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/apk/controller/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..870d7d2
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/apk/controller/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..2571c3b
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-xhdpi/sockstun.png b/apk/controller/app/src/main/res/mipmap-xhdpi/sockstun.png
new file mode 100644
index 0000000..6827d93
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xhdpi/sockstun.png differ
diff --git a/apk/controller/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/apk/controller/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..306a481
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/apk/controller/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..d003525
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-xxhdpi/sockstun.png b/apk/controller/app/src/main/res/mipmap-xxhdpi/sockstun.png
new file mode 100644
index 0000000..db92d53
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xxhdpi/sockstun.png differ
diff --git a/apk/controller/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/apk/controller/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..2c6595b
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/apk/controller/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..86dd841
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/apk/controller/app/src/main/res/mipmap-xxxhdpi/sockstun.png b/apk/controller/app/src/main/res/mipmap-xxxhdpi/sockstun.png
new file mode 100644
index 0000000..464143f
Binary files /dev/null and b/apk/controller/app/src/main/res/mipmap-xxxhdpi/sockstun.png differ
diff --git a/apk/controller/app/src/main/res/values-night/themes.xml b/apk/controller/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..1af1266
--- /dev/null
+++ b/apk/controller/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/apk/controller/app/src/main/res/values-ru-rRU/strings.xml b/apk/controller/app/src/main/res/values-ru-rRU/strings.xml
new file mode 100644
index 0000000..d65f3ec
--- /dev/null
+++ b/apk/controller/app/src/main/res/values-ru-rRU/strings.xml
@@ -0,0 +1,20 @@
+
+
+ SocksTun
+ Адрес Socks:
+ UDP-aдрес Socks:
+ Порт Socks:
+ Имя пользователя Socks:
+ Пароль Socks:
+ DNS IPv4:
+ DNS IPv6:
+ UDP через TCP
+ Удалённый DNS
+ IPv4
+ IPv6
+ Глобально
+ Приложения
+ Сохранить
+ Включить
+ Отключить
+
diff --git a/apk/controller/app/src/main/res/values/attrs.xml b/apk/controller/app/src/main/res/values/attrs.xml
new file mode 100644
index 0000000..448cecf
--- /dev/null
+++ b/apk/controller/app/src/main/res/values/attrs.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apk/controller/app/src/main/res/values/colors.xml b/apk/controller/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..9f76478
--- /dev/null
+++ b/apk/controller/app/src/main/res/values/colors.xml
@@ -0,0 +1,21 @@
+
+
+ #1A1A1A
+ #000000
+ #2E7D32
+ #FFFFFF
+ #000000
+
+ #121212
+
+
+ #D32F2F
+ #1976D2
+ #C62828
+ #2E7D32
+
+
+ #333333
+ #F5F5F5
+ #1A1A1A
+
diff --git a/apk/controller/app/src/main/res/values/strings.xml b/apk/controller/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..e797ba3
--- /dev/null
+++ b/apk/controller/app/src/main/res/values/strings.xml
@@ -0,0 +1,21 @@
+
+
+ IIAB-oA Controller
+ Socks Address:
+ Socks UDP Address:
+ Socks Port:
+ Socks Username:
+ Socks Password:
+ DNS IPv4:
+ DNS IPv6:
+ UDP relay over TCP
+ Remote DNS
+ IPv4
+ IPv6
+ Global
+ Apps
+ Save
+ Enable Safe Pocket Web
+ Disable Safe Pocket Web
+ Enable friendly URLs. Lock out the threats.
+
diff --git a/apk/controller/app/src/main/res/values/themes.xml b/apk/controller/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..46ae9e3
--- /dev/null
+++ b/apk/controller/app/src/main/res/values/themes.xml
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/apk/controller/build.gradle b/apk/controller/build.gradle
new file mode 100644
index 0000000..9a247d3
--- /dev/null
+++ b/apk/controller/build.gradle
@@ -0,0 +1,49 @@
+/**
+ * The buildscript block is where you configure the repositories and
+ * dependencies for Gradle itself--meaning, you should not include dependencies
+ * for your modules here. For example, this block includes the Android plugin for
+ * Gradle as a dependency because it provides the additional instructions Gradle
+ * needs to build Android app modules.
+ */
+
+buildscript {
+
+ /**
+ * The repositories block configures the repositories Gradle uses to
+ * search or download the dependencies. Gradle pre-configures support for remote
+ * repositories such as JCenter, Maven Central, and Ivy. You can also use local
+ * repositories or define your own remote repositories. The code below defines
+ * JCenter as the repository Gradle should use to look for its dependencies.
+ */
+
+ repositories {
+ jcenter()
+ google()
+ }
+
+ /**
+ * The dependencies block configures the dependencies Gradle needs to use
+ * to build your project. The following line adds Android plugin for Gradle
+ * version 2.3.1 as a classpath dependency.
+ */
+
+ dependencies {
+ classpath 'com.android.tools.build:gradle:8.4.1'
+ }
+}
+
+/**
+ * The allprojects block is where you configure the repositories and
+ * dependencies used by all modules in your project, such as third-party plugins
+ * or libraries. Dependencies that are not required by all the modules in the
+ * project should be configured in module-level build.gradle files. For new
+ * projects, Android Studio configures JCenter as the default repository, but it
+ * does not configure any dependencies.
+ */
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ }
+}
diff --git a/apk/controller/debug.keystore b/apk/controller/debug.keystore
new file mode 100644
index 0000000..d985f1e
Binary files /dev/null and b/apk/controller/debug.keystore differ
diff --git a/apk/controller/fastlane/metadata/android/en-US/full_description.txt b/apk/controller/fastlane/metadata/android/en-US/full_description.txt
new file mode 100644
index 0000000..6c5b8df
--- /dev/null
+++ b/apk/controller/fastlane/metadata/android/en-US/full_description.txt
@@ -0,0 +1,12 @@
+A simple and lightweight VPN over socks5 proxy for Android. It is based on a high-performance and low-overhead tun2socks.
+
+
Features
+
+
+
Redirect TCP connections.
+
Redirect UDP packets. (Fullcone NAT, UDP in UDP/TCP)
+
Simple username/password authentication.
+
Specifying DNS addresses.
+
IPv4/IPv6 dual stack.
+
Global/per-App modes.
+
diff --git a/apk/controller/fastlane/metadata/android/en-US/images/icon.png b/apk/controller/fastlane/metadata/android/en-US/images/icon.png
new file mode 100644
index 0000000..3823547
Binary files /dev/null and b/apk/controller/fastlane/metadata/android/en-US/images/icon.png differ
diff --git a/apk/controller/fastlane/metadata/android/en-US/short_description.txt b/apk/controller/fastlane/metadata/android/en-US/short_description.txt
new file mode 100644
index 0000000..acf4d0d
--- /dev/null
+++ b/apk/controller/fastlane/metadata/android/en-US/short_description.txt
@@ -0,0 +1 @@
+A simple and lightweight VPN over socks5 proxy (tun2socks)
diff --git a/apk/controller/fastlane/metadata/android/ru-RU/full_description.txt b/apk/controller/fastlane/metadata/android/ru-RU/full_description.txt
new file mode 100644
index 0000000..436db50
--- /dev/null
+++ b/apk/controller/fastlane/metadata/android/ru-RU/full_description.txt
@@ -0,0 +1,12 @@
+Простое и лёгкое VPN-решение поверх Socks5 прокси для Android. Основано на высокопроизводительном и малозатратном tun2socks.
+
+
Особенности
+
+
+
Перенаправление TCP-соединений.
+
Перенаправление UDP-пакетов. (Fullcone NAT, UDP внутри UDP/TCP)
+
Простая аутентификация по имени пользователя и паролю.
+
Указание адресов DNS.
+
Поддержка двойного стека IPv4/IPv6.
+
Глобальный режим и режим для отдельных приложений.
+
diff --git a/apk/controller/fastlane/metadata/android/ru-RU/short_description.txt b/apk/controller/fastlane/metadata/android/ru-RU/short_description.txt
new file mode 100644
index 0000000..5a7458f
--- /dev/null
+++ b/apk/controller/fastlane/metadata/android/ru-RU/short_description.txt
@@ -0,0 +1 @@
+Простое и лёгкое VPN поверх Socks5 прокси (tun2socks)
diff --git a/apk/controller/gradle.properties b/apk/controller/gradle.properties
new file mode 100644
index 0000000..dbb7bf7
--- /dev/null
+++ b/apk/controller/gradle.properties
@@ -0,0 +1,2 @@
+android.enableJetifier=true
+android.useAndroidX=true
diff --git a/apk/controller/gradle/wrapper/gradle-wrapper.jar b/apk/controller/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..e644113
Binary files /dev/null and b/apk/controller/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/apk/controller/gradle/wrapper/gradle-wrapper.properties b/apk/controller/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..5889900
--- /dev/null
+++ b/apk/controller/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionSha256Sum=a4b4158601f8636cdeeab09bd76afb640030bb5b144aafe261a5e8af027dc612
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/apk/controller/gradlew b/apk/controller/gradlew
new file mode 100755
index 0000000..83f2acf
--- /dev/null
+++ b/apk/controller/gradlew
@@ -0,0 +1,188 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# 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
+#
+# https://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.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/apk/controller/gradlew.bat b/apk/controller/gradlew.bat
new file mode 100644
index 0000000..24467a1
--- /dev/null
+++ b/apk/controller/gradlew.bat
@@ -0,0 +1,100 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/apk/controller/local.properties b/apk/controller/local.properties
new file mode 100644
index 0000000..7d7f23a
--- /dev/null
+++ b/apk/controller/local.properties
@@ -0,0 +1,8 @@
+## This file must *NOT* be checked into Version Control Systems,
+# as it contains information specific to your local configuration.
+#
+# Location of the SDK. This is only used by Gradle.
+# For customization when using a Version Control System, please read the
+# header note.
+#Tue Mar 03 10:51:56 CST 2026
+sdk.dir=/home/ark/Android/Sdk
diff --git a/apk/controller/settings.gradle b/apk/controller/settings.gradle
new file mode 100644
index 0000000..e7b4def
--- /dev/null
+++ b/apk/controller/settings.gradle
@@ -0,0 +1 @@
+include ':app'
diff --git a/apk/controller/store.properties b/apk/controller/store.properties
new file mode 100644
index 0000000..e9bdad8
--- /dev/null
+++ b/apk/controller/store.properties
@@ -0,0 +1,4 @@
+storeFile=debug.keystore
+keyAlias=androiddebugkey
+storePassword=android
+keyPassword=android