diff --git a/.github/workflows/deploy_android_kotlin.yml b/.github/workflows/deploy_android_kotlin.yml
new file mode 100644
index 000000000..af8ae2f74
--- /dev/null
+++ b/.github/workflows/deploy_android_kotlin.yml
@@ -0,0 +1,49 @@
+name: Build Android Kotlin
+on: [push,pull_request,workflow_dispatch]
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+ env:
+ CMAKE_VERSION: 3.18.1
+ NDK_VERSION: 22.1.7171670
+
+ steps:
+ - name: Checkout 🛎️
+ uses: actions/checkout@v4
+
+ - name: Setup Ninja
+ uses: seanmiddleditch/gha-setup-ninja@master
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin' # See 'Supported distributions' for available options
+ java-version: '17'
+
+ - name: Set up cache
+ uses: actions/cache@v4.2.0
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ key: ${{ runner.os }}-android-${{ github.sha }}
+ restore-keys: |
+ ${{ runner.os }}-android-
+
+ - name: Install CMake & Android NDK
+ run: echo "yes" | $ANDROID_HOME/tools/bin/sdkmanager "cmake;${{ env.CMAKE_VERSION }}" "ndk;${{ env.NDK_VERSION }}" --channel=3 | grep -v = || true
+
+ - name: Build Release apk
+ run: |
+ cd tools/android_project_kotlin/
+ chmod +x ./gradlew && ./gradlew assembleRelease
+ ls app/build/outputs/apk/release/
+
+ - name: GH Release 🚀
+ # You may pin to the exact commit or the version.
+ uses: actions/upload-artifact@v4
+ with:
+ name: AndroidRelease
+ path: tools/android_project_kotlin/app/build/outputs/apk/release/com.sky.SkyEmu-*-release.apk
+
+
diff --git a/src/main.c b/src/main.c
index bfeb5874a..7e03d2d26 100644
--- a/src/main.c
+++ b/src/main.c
@@ -1535,6 +1535,14 @@ void se_load_search_paths(){
char settings_path[SB_FILE_PATH_SIZE];
snprintf(settings_path,SB_FILE_PATH_SIZE,"%ssearch_paths.bin",se_get_pref_path());
if(!sb_load_file_data_into_buffer(settings_path,(void*)&gui_state.paths,sizeof(gui_state.paths)))memset(&gui_state.paths,0,sizeof(gui_state.paths));
+
+ #ifdef PLATFORM_ANDROID
+ const char *android_private_path = "/data/data/com.sky.SkyEmu/files/";
+ snprintf(gui_state.paths.save, SB_FILE_PATH_SIZE, "%s/save/", android_private_path);
+ snprintf(gui_state.paths.bios, SB_FILE_PATH_SIZE, "%s/bios/", android_private_path);
+ snprintf(gui_state.paths.cheat_codes, SB_FILE_PATH_SIZE, "%s/cheat_codes/", android_private_path);
+ #endif
+
char * paths[]={
gui_state.paths.save,
gui_state.paths.bios,
@@ -8843,6 +8851,7 @@ static void headless_mode(){
void Java_com_sky_SkyEmu_EnhancedNativeActivity_se_1android_1load_1rom(JNIEnv *env, jobject thiz, jstring filePath) {
const char *nativeFilePath = (*env)->GetStringUTFChars(env, filePath, 0);
gui_state.ran_from_launcher=true;
+ gui_state.settings.save_to_path = false;
se_load_rom(nativeFilePath);
(*env)->ReleaseStringUTFChars(env, filePath, nativeFilePath);
}
diff --git a/tools/android_project_kotlin/README.md b/tools/android_project_kotlin/README.md
new file mode 100644
index 000000000..6f65c9fc1
--- /dev/null
+++ b/tools/android_project_kotlin/README.md
@@ -0,0 +1,52 @@
+Native Activity
+===============
+Native Activity is an Android sample that initializes a GLES 2.0 context and reads accelerometer data from C code using [Native Activity](http://developer.android.com/reference/android/app/NativeActivity.html).
+
+This sample uses the new [Android Studio CMake plugin](http://tools.android.com/tech-docs/external-c-builds) with C++ support.
+
+Pre-requisites
+--------------
+- Android Studio 2.2+ with [NDK](https://developer.android.com/ndk/) bundle.
+
+Getting Started
+---------------
+1. [Download Android Studio](http://developer.android.com/sdk/index.html)
+1. Launch Android Studio.
+1. Open the sample directory.
+1. Open *File/Project Structure...*
+ - Click *Download* or *Select NDK location*.
+1. Click *Tools/Android/Sync Project with Gradle Files*.
+1. Click *Run/Run 'app'*.
+
+Screenshots
+-----------
+
+
+Support
+-------
+If you've found an error in these samples, please [file an issue](https://github.com/googlesamples/android-ndk/issues/new).
+
+Patches are encouraged, and may be submitted by [forking this project](https://github.com/googlesamples/android-ndk/fork) and
+submitting a pull request through GitHub. Please see [CONTRIBUTING.md](../CONTRIBUTING.md) for more details.
+
+- [Stack Overflow](http://stackoverflow.com/questions/tagged/android-ndk)
+- [Android Tools Feedbacks](http://tools.android.com/feedback)
+
+License
+-------
+Copyright 2015 Google, Inc.
+
+Licensed to the Apache Software Foundation (ASF) under one or more contributor
+license agreements. See the NOTICE file distributed with this work for
+additional information regarding copyright ownership. The ASF licenses this
+file to you 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.
diff --git a/tools/android_project_kotlin/app/build.gradle.kts b/tools/android_project_kotlin/app/build.gradle.kts
new file mode 100644
index 000000000..90c2e6cec
--- /dev/null
+++ b/tools/android_project_kotlin/app/build.gradle.kts
@@ -0,0 +1,93 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("kotlin-parcelize")
+ kotlin("plugin.serialization") version "2.1.0"
+ id("androidx.navigation.safeargs.kotlin")
+}
+
+android {
+ signingConfigs {
+ create("release") {
+ storePassword = "skyemu-super-secure-password"
+ keyAlias = "skyemu-open-code-certificate"
+ keyPassword = "skyemu-super-secure-password"
+ storeFile = file("../skyemu-open-signing-store")
+ }
+ }
+ namespace = "com.sky.SkyEmu"
+ compileSdk = 34
+ ndkVersion = "22.1.7171670"
+
+ defaultConfig {
+ signingConfig = signingConfigs.getByName("release")
+ applicationId = "com.sky.SkyEmu"
+ minSdk = 28
+ targetSdk = 34
+ versionCode = 32
+ versionName = "v3.2"
+ setProperty("archivesBaseName", "$applicationId-v$versionCode")
+ externalNativeBuild {
+ cmake {
+ arguments += listOf(
+ "-DANDROID_STL=c++_static",
+ "-DANDROID=1",
+ "-DNDK_DEBUG=0",
+ "-DCMAKE_BUILD_TYPE=RELWITHDEBINFO",
+ "-DANDROID_ARM_NEON=TRUE"
+ )
+ targets += "SkyEmu"
+ }
+ }
+ ndk {
+ abiFilters += listOf("arm64-v8a")
+ }
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ version = "3.22.1"
+ path = file("../../../CMakeLists.txt")
+ }
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(fileTree("libs") { include("*.jar") })
+ implementation("androidx.activity:activity-ktx:1.9.3")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.documentfile:documentfile:1.0.1")
+ implementation("androidx.fragment:fragment-ktx:1.8.5")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
+ implementation("androidx.navigation:navigation-fragment-ktx:2.8.4")
+ implementation("androidx.navigation:navigation-ui-ktx:2.8.4")
+ implementation("androidx.preference:preference-ktx:1.2.1")
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.browser:browser:1.5.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+}
diff --git a/tools/android_project_kotlin/app/release/output-metadata.json b/tools/android_project_kotlin/app/release/output-metadata.json
new file mode 100644
index 000000000..5546cf533
--- /dev/null
+++ b/tools/android_project_kotlin/app/release/output-metadata.json
@@ -0,0 +1,18 @@
+{
+ "version": 2,
+ "artifactType": {
+ "type": "APK",
+ "kind": "Directory"
+ },
+ "applicationId": "com.sky.SkyEmu",
+ "variantName": "release",
+ "elements": [
+ {
+ "type": "SINGLE",
+ "filters": [],
+ "versionCode": 0,
+ "versionName": "",
+ "outputFile": "app-release.apk"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/AndroidManifest.xml b/tools/android_project_kotlin/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..999d6905f
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/AndroidManifest.xml
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/EnhancedNativeActivity.java b/tools/android_project_kotlin/app/src/main/EnhancedNativeActivity.java
new file mode 100644
index 000000000..7b0b506fe
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/EnhancedNativeActivity.java
@@ -0,0 +1,17 @@
+package com.sky.SkyEmu;
+import android.content.Context;
+import android.view.inputmethod.InputMethodManager;
+public class EnhancedNativeActivity extends android.app.NativeActivity {
+ private static final String TAG = "EnhancedNativeActivity";
+ public void print_hello(){
+ android.util.Log.v(TAG, "Hello World\n");
+ }
+ public void showKeyboard() {
+ InputMethodManager imm = ( InputMethodManager )getSystemService( Context.INPUT_METHOD_SERVICE );
+ imm.showSoftInput( this.getWindow().getDecorView(), InputMethodManager.SHOW_FORCED );
+ }
+ public void hideKeyboard(){
+ InputMethodManager imm = ( InputMethodManager )getSystemService( Context.INPUT_METHOD_SERVICE );
+ imm.hideSoftInputFromWindow( this.getWindow().getDecorView().getWindowToken(), 0 );
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/cpp/CMakeLists.txt b/tools/android_project_kotlin/app/src/main/cpp/CMakeLists.txt
new file mode 100644
index 000000000..2ecfd9eae
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,43 @@
+#
+# Copyright (C) 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.
+#
+
+cmake_minimum_required(VERSION 3.4.1)
+
+# build native_app_glue as a static lib
+set(${CMAKE_C_FLAGS}, "${CMAKE_C_FLAGS}")
+add_library(native_app_glue STATIC
+ ${ANDROID_NDK}/sources/android/native_app_glue/android_native_app_glue.c)
+
+# now build app's shared lib
+set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++11 -Wall -Werror")
+
+# Export ANativeActivity_onCreate(),
+# Refer to: https://github.com/android-ndk/ndk/issues/381.
+set(CMAKE_SHARED_LINKER_FLAGS
+ "${CMAKE_SHARED_LINKER_FLAGS} -u ANativeActivity_onCreate")
+
+add_library(native-activity SHARED main.cpp)
+
+target_include_directories(native-activity PRIVATE
+ ${ANDROID_NDK}/sources/android/native_app_glue)
+
+# add lib dependencies
+target_link_libraries(native-activity
+ android
+ native_app_glue
+ EGL
+ GLESv1_CM
+ log)
diff --git a/tools/android_project_kotlin/app/src/main/cpp/main.cpp b/tools/android_project_kotlin/app/src/main/cpp/main.cpp
new file mode 100644
index 000000000..fe340681d
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/cpp/main.cpp
@@ -0,0 +1,391 @@
+/*
+ * Copyright (C) 2010 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.
+ *
+ */
+
+//BEGIN_INCLUDE(all)
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+
+#define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "native-activity", __VA_ARGS__))
+#define LOGW(...) ((void)__android_log_print(ANDROID_LOG_WARN, "native-activity", __VA_ARGS__))
+
+/**
+ * Our saved state data.
+ */
+struct saved_state {
+ float angle;
+ int32_t x;
+ int32_t y;
+};
+
+/**
+ * Shared state for our app.
+ */
+struct engine {
+ struct android_app* app;
+
+ ASensorManager* sensorManager;
+ const ASensor* accelerometerSensor;
+ ASensorEventQueue* sensorEventQueue;
+
+ int animating;
+ EGLDisplay display;
+ EGLSurface surface;
+ EGLContext context;
+ int32_t width;
+ int32_t height;
+ struct saved_state state;
+};
+
+/**
+ * Initialize an EGL context for the current display.
+ */
+static int engine_init_display(struct engine* engine) {
+ // initialize OpenGL ES and EGL
+
+ /*
+ * Here specify the attributes of the desired configuration.
+ * Below, we select an EGLConfig with at least 8 bits per color
+ * component compatible with on-screen windows
+ */
+ const EGLint attribs[] = {
+ EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
+ EGL_BLUE_SIZE, 8,
+ EGL_GREEN_SIZE, 8,
+ EGL_RED_SIZE, 8,
+ EGL_NONE
+ };
+ EGLint w, h, format;
+ EGLint numConfigs;
+ EGLConfig config = nullptr;
+ EGLSurface surface;
+ EGLContext context;
+
+ EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
+
+ eglInitialize(display, nullptr, nullptr);
+
+ /* Here, the application chooses the configuration it desires.
+ * find the best match if possible, otherwise use the very first one
+ */
+ eglChooseConfig(display, attribs, nullptr,0, &numConfigs);
+ std::unique_ptr supportedConfigs(new EGLConfig[numConfigs]);
+ assert(supportedConfigs);
+ eglChooseConfig(display, attribs, supportedConfigs.get(), numConfigs, &numConfigs);
+ assert(numConfigs);
+ auto i = 0;
+ for (; i < numConfigs; i++) {
+ auto& cfg = supportedConfigs[i];
+ EGLint r, g, b, d;
+ if (eglGetConfigAttrib(display, cfg, EGL_RED_SIZE, &r) &&
+ eglGetConfigAttrib(display, cfg, EGL_GREEN_SIZE, &g) &&
+ eglGetConfigAttrib(display, cfg, EGL_BLUE_SIZE, &b) &&
+ eglGetConfigAttrib(display, cfg, EGL_DEPTH_SIZE, &d) &&
+ r == 8 && g == 8 && b == 8 && d == 0 ) {
+
+ config = supportedConfigs[i];
+ break;
+ }
+ }
+ if (i == numConfigs) {
+ config = supportedConfigs[0];
+ }
+
+ if (config == nullptr) {
+ LOGW("Unable to initialize EGLConfig");
+ return -1;
+ }
+
+ /* EGL_NATIVE_VISUAL_ID is an attribute of the EGLConfig that is
+ * guaranteed to be accepted by ANativeWindow_setBuffersGeometry().
+ * As soon as we picked a EGLConfig, we can safely reconfigure the
+ * ANativeWindow buffers to match, using EGL_NATIVE_VISUAL_ID. */
+ eglGetConfigAttrib(display, config, EGL_NATIVE_VISUAL_ID, &format);
+ surface = eglCreateWindowSurface(display, config, engine->app->window, nullptr);
+ context = eglCreateContext(display, config, nullptr, nullptr);
+
+ if (eglMakeCurrent(display, surface, surface, context) == EGL_FALSE) {
+ LOGW("Unable to eglMakeCurrent");
+ return -1;
+ }
+
+ eglQuerySurface(display, surface, EGL_WIDTH, &w);
+ eglQuerySurface(display, surface, EGL_HEIGHT, &h);
+
+ engine->display = display;
+ engine->context = context;
+ engine->surface = surface;
+ engine->width = w;
+ engine->height = h;
+ engine->state.angle = 0;
+
+ // Check openGL on the system
+ auto opengl_info = {GL_VENDOR, GL_RENDERER, GL_VERSION, GL_EXTENSIONS};
+ for (auto name : opengl_info) {
+ auto info = glGetString(name);
+ LOGI("OpenGL Info: %s", info);
+ }
+ // Initialize GL state.
+ glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_FASTEST);
+ glEnable(GL_CULL_FACE);
+ glShadeModel(GL_SMOOTH);
+ glDisable(GL_DEPTH_TEST);
+
+ return 0;
+}
+
+/**
+ * Just the current frame in the display.
+ */
+static void engine_draw_frame(struct engine* engine) {
+ if (engine->display == nullptr) {
+ // No display.
+ return;
+ }
+
+ // Just fill the screen with a color.
+ glClearColor(((float)engine->state.x)/engine->width, engine->state.angle,
+ ((float)engine->state.y)/engine->height, 1);
+ glClear(GL_COLOR_BUFFER_BIT);
+
+ eglSwapBuffers(engine->display, engine->surface);
+}
+
+/**
+ * Tear down the EGL context currently associated with the display.
+ */
+static void engine_term_display(struct engine* engine) {
+ if (engine->display != EGL_NO_DISPLAY) {
+ eglMakeCurrent(engine->display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ if (engine->context != EGL_NO_CONTEXT) {
+ eglDestroyContext(engine->display, engine->context);
+ }
+ if (engine->surface != EGL_NO_SURFACE) {
+ eglDestroySurface(engine->display, engine->surface);
+ }
+ eglTerminate(engine->display);
+ }
+ engine->animating = 0;
+ engine->display = EGL_NO_DISPLAY;
+ engine->context = EGL_NO_CONTEXT;
+ engine->surface = EGL_NO_SURFACE;
+}
+
+/**
+ * Process the next input event.
+ */
+static int32_t engine_handle_input(struct android_app* app, AInputEvent* event) {
+ auto* engine = (struct engine*)app->userData;
+ if (AInputEvent_getType(event) == AINPUT_EVENT_TYPE_MOTION) {
+ engine->animating = 1;
+ engine->state.x = AMotionEvent_getX(event, 0);
+ engine->state.y = AMotionEvent_getY(event, 0);
+ return 1;
+ }
+ return 0;
+}
+
+/**
+ * Process the next main command.
+ */
+static void engine_handle_cmd(struct android_app* app, int32_t cmd) {
+ auto* engine = (struct engine*)app->userData;
+ switch (cmd) {
+ case APP_CMD_SAVE_STATE:
+ // The system has asked us to save our current state. Do so.
+ engine->app->savedState = malloc(sizeof(struct saved_state));
+ *((struct saved_state*)engine->app->savedState) = engine->state;
+ engine->app->savedStateSize = sizeof(struct saved_state);
+ break;
+ case APP_CMD_INIT_WINDOW:
+ // The window is being shown, get it ready.
+ if (engine->app->window != nullptr) {
+ engine_init_display(engine);
+ engine_draw_frame(engine);
+ }
+ break;
+ case APP_CMD_TERM_WINDOW:
+ // The window is being hidden or closed, clean it up.
+ engine_term_display(engine);
+ break;
+ case APP_CMD_GAINED_FOCUS:
+ // When our app gains focus, we start monitoring the accelerometer.
+ if (engine->accelerometerSensor != nullptr) {
+ ASensorEventQueue_enableSensor(engine->sensorEventQueue,
+ engine->accelerometerSensor);
+ // We'd like to get 60 events per second (in us).
+ ASensorEventQueue_setEventRate(engine->sensorEventQueue,
+ engine->accelerometerSensor,
+ (1000L/60)*1000);
+ }
+ break;
+ case APP_CMD_LOST_FOCUS:
+ // When our app loses focus, we stop monitoring the accelerometer.
+ // This is to avoid consuming battery while not being used.
+ if (engine->accelerometerSensor != nullptr) {
+ ASensorEventQueue_disableSensor(engine->sensorEventQueue,
+ engine->accelerometerSensor);
+ }
+ // Also stop animating.
+ engine->animating = 0;
+ engine_draw_frame(engine);
+ break;
+ default:
+ break;
+ }
+}
+
+/*
+ * AcquireASensorManagerInstance(void)
+ * Workaround ASensorManager_getInstance() deprecation false alarm
+ * for Android-N and before, when compiling with NDK-r15
+ */
+#include
+ASensorManager* AcquireASensorManagerInstance(android_app* app) {
+
+ if(!app)
+ return nullptr;
+
+ typedef ASensorManager *(*PF_GETINSTANCEFORPACKAGE)(const char *name);
+ void* androidHandle = dlopen("libandroid.so", RTLD_NOW);
+ auto getInstanceForPackageFunc = (PF_GETINSTANCEFORPACKAGE)
+ dlsym(androidHandle, "ASensorManager_getInstanceForPackage");
+ if (getInstanceForPackageFunc) {
+ JNIEnv* env = nullptr;
+ app->activity->vm->AttachCurrentThread(&env, nullptr);
+
+ jclass android_content_Context = env->GetObjectClass(app->activity->clazz);
+ jmethodID midGetPackageName = env->GetMethodID(android_content_Context,
+ "getPackageName",
+ "()Ljava/lang/String;");
+ auto packageName= (jstring)env->CallObjectMethod(app->activity->clazz,
+ midGetPackageName);
+
+ const char *nativePackageName = env->GetStringUTFChars(packageName, nullptr);
+ ASensorManager* mgr = getInstanceForPackageFunc(nativePackageName);
+ env->ReleaseStringUTFChars(packageName, nativePackageName);
+ app->activity->vm->DetachCurrentThread();
+ if (mgr) {
+ dlclose(androidHandle);
+ return mgr;
+ }
+ }
+
+ typedef ASensorManager *(*PF_GETINSTANCE)();
+ auto getInstanceFunc = (PF_GETINSTANCE)
+ dlsym(androidHandle, "ASensorManager_getInstance");
+ // by all means at this point, ASensorManager_getInstance should be available
+ assert(getInstanceFunc);
+ dlclose(androidHandle);
+
+ return getInstanceFunc();
+}
+
+
+/**
+ * This is the main entry point of a native application that is using
+ * android_native_app_glue. It runs in its own thread, with its own
+ * event loop for receiving input events and doing other things.
+ */
+void android_main(struct android_app* state) {
+ struct engine engine{};
+
+ memset(&engine, 0, sizeof(engine));
+ state->userData = &engine;
+ state->onAppCmd = engine_handle_cmd;
+ state->onInputEvent = engine_handle_input;
+ engine.app = state;
+
+ // Prepare to monitor accelerometer
+ engine.sensorManager = AcquireASensorManagerInstance(state);
+ engine.accelerometerSensor = ASensorManager_getDefaultSensor(
+ engine.sensorManager,
+ ASENSOR_TYPE_ACCELEROMETER);
+ engine.sensorEventQueue = ASensorManager_createEventQueue(
+ engine.sensorManager,
+ state->looper, LOOPER_ID_USER,
+ nullptr, nullptr);
+
+ if (state->savedState != nullptr) {
+ // We are starting with a previous saved state; restore from it.
+ engine.state = *(struct saved_state*)state->savedState;
+ }
+
+ // loop waiting for stuff to do.
+
+ while (true) {
+ // Read all pending events.
+ int ident;
+ int events;
+ struct android_poll_source* source;
+
+ // If not animating, we will block forever waiting for events.
+ // If animating, we loop until all events are read, then continue
+ // to draw the next frame of animation.
+ while ((ident=ALooper_pollAll(engine.animating ? 0 : -1, nullptr, &events,
+ (void**)&source)) >= 0) {
+
+ // Process this event.
+ if (source != nullptr) {
+ source->process(state, source);
+ }
+
+ // If a sensor has data, process it now.
+ if (ident == LOOPER_ID_USER) {
+ if (engine.accelerometerSensor != nullptr) {
+ ASensorEvent event;
+ while (ASensorEventQueue_getEvents(engine.sensorEventQueue,
+ &event, 1) > 0) {
+ LOGI("accelerometer: x=%f y=%f z=%f",
+ event.acceleration.x, event.acceleration.y,
+ event.acceleration.z);
+ }
+ }
+ }
+
+ // Check if we are exiting.
+ if (state->destroyRequested != 0) {
+ engine_term_display(&engine);
+ return;
+ }
+ }
+
+ if (engine.animating) {
+ // Done with events; draw next animation frame.
+ engine.state.angle += .01f;
+ if (engine.state.angle > 1) {
+ engine.state.angle = 0;
+ }
+
+ // Drawing is throttled to the screen update rate, so there
+ // is no need to do timing here.
+ engine_draw_frame(&engine);
+ }
+ }
+}
+//END_INCLUDE(all)
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/EnhancedNativeActivity.java b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/EnhancedNativeActivity.java
new file mode 100644
index 000000000..1f9844362
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/EnhancedNativeActivity.java
@@ -0,0 +1,426 @@
+package com.sky.SkyEmu;
+
+import static android.view.InputDevice.SOURCE_GAMEPAD;
+import static android.view.InputDevice.SOURCE_JOYSTICK;
+import static android.view.KeyEvent.*;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.graphics.Rect;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.provider.OpenableColumns;
+import android.text.InputType;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewTreeObserver;
+import android.view.Window;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+
+import android.app.NativeActivity;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.browser.customtabs.CustomTabsIntent;
+
+import com.sky.SkyEmu.models.Game;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.security.Key;
+import java.util.Locale;
+import java.util.Vector;
+
+public class EnhancedNativeActivity extends NativeActivity {
+ final static int APP_STORAGE_ACCESS_REQUEST_CODE = 501; // Any value
+ final static int STORAGE_PERMISSION_CODE = 501; // Any value
+ final static int FILE_PICKER_REQUEST_CODE = 123;
+ final static String TAG="SkyEmu"; // Any value
+ public Rect visibleRect;
+ public EditText invisibleEditText;
+ public View mRootView;
+ private Vector keyboardEvents;
+ private boolean first_event;
+ CustomTabsIntent authIntent;
+
+ static {
+ System.loadLibrary("SkyEmu");
+ }
+
+ public void requestPermissions() {}
+
+ public float getDPIScale() {
+ DisplayMetrics metrics = getResources().getDisplayMetrics();
+ getWindowManager().getDefaultDisplay().getRealMetrics(metrics);
+ return metrics.xdpi/120.0f;
+ }
+
+ public static String getLanguage() {
+ return Locale.getDefault().toString();
+ }
+
+ /*Handle permission request results*/
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode == STORAGE_PERMISSION_CODE){
+ if (grantResults.length > 0){
+ //check each permission if granted or not
+ boolean write = grantResults[0] == PackageManager.PERMISSION_GRANTED;
+ boolean read = grantResults[1] == PackageManager.PERMISSION_GRANTED;
+
+ if (write && read){
+ //External Storage permissions granted
+ Log.d(TAG, "onRequestPermissionsResult: External Storage permissions granted");
+ }
+ else{
+ //External Storage permission denied
+ Log.d(TAG, "onRequestPermissionsResult: External Storage permission denied");
+ }
+ }
+ }
+ }
+
+ public float getVisibleBottom() {
+ return visibleRect.bottom;
+ }
+
+ public float getVisibleTop() {
+ return visibleRect.top;
+ }
+
+ public int getEvent() {
+ if(first_event){
+ Intent intent = getIntent();
+ Uri data = intent.getData();
+ if (intent.getAction()==Intent.ACTION_VIEW&&data != null) {
+ loadURI(data,true);
+ }
+ first_event=false;
+ }
+ if(keyboardEvents.isEmpty())return -1;
+ int val = keyboardEvents.get(0);
+ keyboardEvents.remove(0);
+ return val;
+ }
+
+ public void showKeyboard() {
+ Window win =this.getWindow();
+ NativeActivity activity = this;
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if(invisibleEditText==null){
+ FrameLayout.LayoutParams mRparams = new FrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT);
+ invisibleEditText = new EditText(activity);
+ invisibleEditText.setLayoutParams(mRparams);
+ invisibleEditText.setRawInputType(InputType.TYPE_CLASS_TEXT);
+ invisibleEditText.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
+ // Set an OnKeyListener to intercept key events
+ invisibleEditText.setOnKeyListener(new View.OnKeyListener() {
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ // Consume the key event to prevent it from reaching the EditText
+ // So, that we don not duplicate the inputs relayed through the C code for onKey.
+ return true;
+ }
+ });
+ ((FrameLayout)mRootView).addView(invisibleEditText);
+ }
+ invisibleEditText.requestFocus();
+ InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(invisibleEditText, InputMethodManager.SHOW_IMPLICIT);
+ }
+ });
+ }
+
+ public void hideKeyboard() {
+ Window win =this.getWindow();
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ ((FrameLayout)mRootView).removeView(invisibleEditText);
+ invisibleEditText = null;
+ }
+ });
+ }
+
+ public void pollKeyboard(){
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if(invisibleEditText==null)return;
+ int pre = 0;
+ boolean inserted = false;
+ if(invisibleEditText.getSelectionEnd()!= invisibleEditText.getText().length()-8){
+ int distance = invisibleEditText.getText().length()-invisibleEditText.getSelectionEnd()-8;
+ for(int c=0; c=invisibleEditText.getText().length()-8){
+ break;
+ }
+ pre++;
+ inserted=true;
+ //Enter
+ if(c=='\n'){keyboardEvents.add(13 |0x40000000);
+ }else keyboardEvents.add(c);
+ }
+ while (pre < 8) {
+ inserted=true;
+ keyboardEvents.add(11 | 0x40000000);
+ pre++;
+ }
+ if(inserted) {
+ invisibleEditText.setText("\1\1\1\1\1\1\1\1\2\2\2\2\2\2\2\2");
+ invisibleEditText.setSelection(invisibleEditText.getText().length()-8);
+ }
+ if(invisibleEditText.getSelectionEnd()!= invisibleEditText.getText().length()-8)
+ invisibleEditText.setSelection(invisibleEditText.getText().length()-8);
+ }
+ });
+ }
+
+ private File copyFileToExternalDirectory(Uri sourceFilePath, String destinationDirectoryPath, String filename) {
+ File sourceFile = new File(sourceFilePath.getPath());
+ if(sourceFile!=null)Log.i("FilePicker","Source File Exists\n");
+ File destinationDirectory = new File(destinationDirectoryPath);
+ if(destinationDirectory!=null)Log.i("FilePicker","Destination File Exists\n");
+
+ if (!destinationDirectory.exists()) {
+ destinationDirectory.mkdirs();
+ }
+
+ File copiedFile = new File(destinationDirectory, filename);
+ if(copiedFile!=null)Log.i("FilePicker","Copied File Exists\n");
+
+ try (InputStream in = getContentResolver().openInputStream(sourceFilePath);
+ OutputStream out = new FileOutputStream(copiedFile)) {
+ byte[] buffer = new byte[1024];
+ int length;
+ while ((length = in.read(buffer)) > 0) {
+ out.write(buffer, 0, length);
+ }
+ Log.i("FilePicker","Done copying\n");
+ return copiedFile;
+ } catch (IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ protected void onCreate(Bundle savedInstanceState) {
+ first_event=true;
+ super.onCreate(savedInstanceState);
+ Window mRootWindow = getWindow();
+ mRootView = mRootWindow.getDecorView().findViewById(android.R.id.content);
+ invisibleEditText=null;
+ keyboardEvents = new Vector(5);
+
+ EnhancedNativeActivity activity = this;
+ mRootView.getViewTreeObserver().addOnGlobalLayoutListener(
+ new ViewTreeObserver.OnGlobalLayoutListener() {
+ public void onGlobalLayout(){
+ Rect r = new Rect();
+ View view = mRootWindow.getDecorView();
+ view.getWindowVisibleDisplayFrame(r);
+ activity.visibleRect = r;
+ }
+ });
+
+ int currentApiVersion = Build.VERSION.SDK_INT;
+
+ final int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+ mRootWindow.getDecorView().setOnGenericMotionListener(new View.OnGenericMotionListener() {
+ @Override
+ public boolean onGenericMotion(View view, MotionEvent event) {
+ for(int i=0;i= Build.VERSION_CODES.KITKAT){
+ getWindow().getDecorView().setSystemUiVisibility(flags);
+
+ // Code below is to handle presses of Volume up or Volume down.
+ // Without this, after pressing volume buttons, the navigation bar will
+ // show up and won't hide
+ final View decorView = getWindow().getDecorView();
+ decorView
+ .setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener()
+ {
+ @Override
+ public void onSystemUiVisibilityChange(int visibility)
+ {
+ if((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0)
+ {
+ decorView.setSystemUiVisibility(flags);
+ }
+ }
+ });
+ }
+
+ Bundle extras = getIntent().getExtras();
+ if (extras != null) {
+ Game game = extras.getParcelable("game");
+ if (game != null) {
+ loadURI(Uri.parse(game.getPath()), true);
+ }
+ }
+ }
+
+ public void openFile(){
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.setType("*/*");
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
+ startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
+ }
+
+ public String getFileName(Uri uri) {
+ String result = null;
+ if (uri.getScheme().equals("content")) {
+ Cursor cursor = getContentResolver().query(uri, null, null, null, null);
+ try {
+ if (cursor != null && cursor.moveToFirst()) {
+ result = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ if (result == null) {
+ result = uri.getPath();
+ int cut = result.lastIndexOf('/');
+ if (cut != -1) {
+ result = result.substring(cut + 1);
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public boolean onKeyDown(int keycode, KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
+ return true;
+ }
+ int gamepad_keycode = event.getKeyCode()&0xffff;
+
+ if((event.getSource() & SOURCE_JOYSTICK)!=SOURCE_JOYSTICK){
+ int skyemu_event = gamepad_keycode | 0x20000000;
+ if(event.getAction()==ACTION_DOWN)skyemu_event|=(1<<16);
+ keyboardEvents.add(skyemu_event);
+ if((event.getSource() & SOURCE_GAMEPAD)==SOURCE_GAMEPAD)return true;
+ }
+ // If the event is not the back button press, let it propagate as usual
+ return false;
+ }
+
+ @Override
+ public boolean onKeyUp(int keycode, KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) {
+ return true;
+ }
+ int gamepad_keycode = event.getKeyCode()&0xffff;
+
+ if((event.getSource() & SOURCE_JOYSTICK)!=SOURCE_JOYSTICK){
+ int skyemu_event = gamepad_keycode | 0x20000000;
+ if(event.getAction()==ACTION_DOWN)skyemu_event|=(1<<16);
+ keyboardEvents.add(skyemu_event);
+ if((event.getSource() & SOURCE_GAMEPAD)==SOURCE_GAMEPAD)return true;
+ }
+ // If the event is not the back button press, let it propagate as usual
+ return false;
+ }
+
+ public void loadURI(Uri selectedFileUri, boolean is_rom){
+ String filename = getFileName(selectedFileUri);
+ File file = new File(selectedFileUri.getPath());//create path from uri
+ Log.i("SkyEmu", "Selected file path: " + filename);
+
+ if (selectedFileUri != null) {
+ // Get the original file's path using its URI
+ // Copy the file to the external directory
+ String externalDirectoryPath = getExternalFilesDir(null).getAbsolutePath(); // Use app private path for now
+ File copiedFile = copyFileToExternalDirectory(selectedFileUri, externalDirectoryPath, filename); // TODO: Implement SAF
+
+ if (copiedFile != null) {
+ String copiedFilePath = copiedFile.getAbsolutePath();
+ if(is_rom)se_android_load_rom(copiedFilePath);
+ se_android_load_file(copiedFilePath);
+ Log.i("SkyEmu", "Copied file path: " + copiedFilePath);
+ }
+ }
+ }
+
+ public void openCustomTab(String url){
+ authIntent = new CustomTabsIntent.Builder().build();
+ authIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ authIntent.launchUrl(EnhancedNativeActivity.this, Uri.parse(url));
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ // If the selection didn't work
+ if (resultCode != RESULT_OK) {
+ // Exit without doing anything else
+ return;
+ } else {
+ if (requestCode == FILE_PICKER_REQUEST_CODE && data != null) {
+ Uri selectedFileUri = data.getData();
+ loadURI(selectedFileUri,false);
+ }
+ }
+ }
+
+ public native void se_android_load_file(String filePath);
+ public native void se_android_load_rom(String filePath);
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/MainActivity.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/MainActivity.kt
new file mode 100644
index 000000000..c1e3dde15
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/MainActivity.kt
@@ -0,0 +1,47 @@
+
+package com.sky.SkyEmu
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.view.ViewGroup.MarginLayoutParams
+import android.view.WindowInsetsController
+import android.view.WindowManager
+import android.view.animation.PathInterpolator
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import com.google.android.material.color.MaterialColors
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.NavController
+import androidx.navigation.ui.setupWithNavController
+import com.google.android.material.navigation.NavigationBarView
+import com.sky.SkyEmu.R
+import com.sky.SkyEmu.databinding.MainActivityBinding
+import kotlinx.coroutines.launch
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: MainActivityBinding
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = MainActivityBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
+
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.fragment_container) as NavHostFragment
+ setUpNavigation(navHostFragment.navController)
+ }
+
+ private fun setUpNavigation(navController: NavController) {
+ (binding.navigationView as NavigationBarView).setupWithNavController(navController)
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/SkyEmuApplication.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/SkyEmuApplication.kt
new file mode 100644
index 000000000..1dc53990e
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/SkyEmuApplication.kt
@@ -0,0 +1,22 @@
+
+package com.sky.SkyEmu
+
+import android.annotation.SuppressLint
+import android.app.Application
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+
+class SkyEmuApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ applicationInstance = this
+ }
+
+ companion object {
+ private var applicationInstance: SkyEmuApplication? = null
+
+ val appContext: Context get() = applicationInstance!!.applicationContext
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/adapters/GameAdapter.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/adapters/GameAdapter.kt
new file mode 100644
index 000000000..8350fa17b
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/adapters/GameAdapter.kt
@@ -0,0 +1,142 @@
+
+package com.sky.SkyEmu.adapters
+
+import android.content.Context
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager
+import android.graphics.drawable.BitmapDrawable
+import android.graphics.drawable.Icon
+import android.net.Uri
+import android.os.SystemClock
+import android.text.TextUtils
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.ViewModelProvider
+import androidx.navigation.findNavController
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.AsyncDifferConfig
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.google.android.material.bottomsheet.BottomSheetBehavior
+import com.google.android.material.bottomsheet.BottomSheetDialog
+import com.google.android.material.button.MaterialButton
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.sky.SkyEmu.HomeNavigationDirections
+import com.sky.SkyEmu.R
+import com.sky.SkyEmu.adapters.GameAdapter.GameViewHolder
+import com.sky.SkyEmu.databinding.AppItemBinding
+import com.sky.SkyEmu.models.Game
+import com.sky.SkyEmu.SkyEmuApplication
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class GameAdapter(private val activity: AppCompatActivity) :
+ ListAdapter(AsyncDifferConfig.Builder(DiffCallback()).build()),
+ View.OnClickListener, View.OnLongClickListener {
+ private var lastClickTime = 0L
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
+ // Create a new view.
+ val binding = AppItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ binding.itemClickLayout.setOnClickListener(this)
+ binding.itemClickLayout.setOnLongClickListener(this)
+
+ // Use that view to create a ViewHolder.
+ return GameViewHolder(binding)
+ }
+
+ override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
+ holder.bind(currentList[position])
+ }
+
+ override fun getItemCount(): Int = currentList.size
+
+ /**
+ * Launches the game that was clicked on.
+ *
+ * @param view The card representing the game the user wants to play.
+ */
+ override fun onClick(view: View) {
+ // Double-click prevention, using threshold of 1000 ms
+ if (SystemClock.elapsedRealtime() - lastClickTime < 1000) {
+ return
+ }
+ lastClickTime = SystemClock.elapsedRealtime()
+
+ val holder = view.tag as GameViewHolder
+ gameExists(holder)
+
+ val action = HomeNavigationDirections.actionGlobalEmulationActivity(holder.game)
+ view.findNavController().navigate(action)
+ }
+
+ /**
+ * Opens the about game dialog for the game that was clicked on.
+ *
+ * @param view The view representing the game the user wants to play.
+ */
+ override fun onLongClick(view: View): Boolean {
+ val context = view.context
+ return true
+ }
+
+ inner class GameViewHolder(val binding: AppItemBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ lateinit var game: Game
+
+ init {
+ binding.itemClickLayout.tag = this
+ }
+
+ fun bind(game: Game) {
+ this.game = game
+ binding.title.text = game.title
+ //binding.version.text = game.version
+ //binding.author.text = game.author
+
+ binding.title.postDelayed(
+ {
+ binding.title.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.title.isSelected = true
+
+ binding.version.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.version.isSelected = true
+
+ binding.author.ellipsize = TextUtils.TruncateAt.MARQUEE
+ binding.author.isSelected = true
+ },
+ 3000
+ )
+ }
+ }
+
+ private fun gameExists(holder: GameViewHolder): Boolean {
+ val gamePath = holder.game.path
+ val file = DocumentFile.fromSingleUri(SkyEmuApplication.appContext, Uri.parse(gamePath))
+ return file?.exists() == true
+ }
+
+
+ private fun isValidGame(extension: String): Boolean {
+ return extension == "gba"
+ }
+
+ private class DiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem.title == newItem.title
+ }
+
+ override fun areContentsTheSame(oldItem: Game, newItem: Game): Boolean {
+ return oldItem == newItem
+ }
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/fragments/.gitkeep b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/fragments/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/fragments/GamesFragment.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/fragments/GamesFragment.kt
new file mode 100644
index 000000000..231be28c7
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/fragments/GamesFragment.kt
@@ -0,0 +1,217 @@
+
+package com.sky.SkyEmu.fragments
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.net.Uri
+import android.content.Intent
+import androidx.activity.result.ActivityResultCallback
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updatePadding
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.activityViewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.preference.PreferenceManager
+import androidx.recyclerview.widget.GridLayoutManager
+import com.google.android.material.color.MaterialColors
+import com.google.android.material.transition.MaterialFadeThrough
+import com.google.android.material.snackbar.Snackbar
+import com.sky.SkyEmu.SkyEmuApplication
+import com.sky.SkyEmu.R
+import com.sky.SkyEmu.adapters.GameAdapter
+import com.sky.SkyEmu.databinding.FragmentGamesBinding
+import com.sky.SkyEmu.models.Game
+import com.sky.SkyEmu.utils.GameUtils
+import com.sky.SkyEmu.utils.LoaderResult
+import com.sky.SkyEmu.viewmodels.GamesViewModel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+class GamesFragment : Fragment() {
+ private var _binding: FragmentGamesBinding? = null
+ private val binding get() = _binding!!
+
+ private val openRomContract = ActivityResultContracts.OpenDocument()
+ private lateinit var pickFileRequest: ActivityResultLauncher>
+
+ private val gamesViewModel: GamesViewModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterTransition = MaterialFadeThrough()
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentGamesBinding.inflate(inflater)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val inflater = LayoutInflater.from(requireContext())
+
+ binding.gridGames.apply {
+ layoutManager = GridLayoutManager(
+ requireContext(),
+ 2
+ )
+ adapter = GameAdapter(requireActivity() as AppCompatActivity)
+ }
+
+ binding.swipeRefresh.apply {
+ // Add swipe down to refresh gesture
+ setOnRefreshListener {
+ gamesViewModel.reloadGames(false)
+ }
+
+ // Set theme color to the refresh animation's background
+ setProgressBackgroundColorSchemeColor(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorPrimary
+ )
+ )
+ setColorSchemeColors(
+ MaterialColors.getColor(
+ binding.swipeRefresh,
+ com.google.android.material.R.attr.colorOnPrimary
+ )
+ )
+ post {
+ if (_binding == null) {
+ return@post
+ }
+ binding.swipeRefresh.isRefreshing = gamesViewModel.isReloading.value
+ }
+ }
+
+ pickFileRequest = registerForActivityResult(openRomContract) { uri: Uri? ->
+ if (uri != null) {
+ val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
+ requireContext().contentResolver.takePersistableUriPermission(uri, flags)
+ val result = GameUtils.addGame(uri)
+ if (result == LoaderResult.Error) Snackbar.make(binding.root, "Unsupported extension", Snackbar.LENGTH_SHORT).show()
+ }
+ }
+
+ binding.add.setOnClickListener {
+ pickFileRequest.launch(arrayOf("*/*"))
+ }
+
+ viewLifecycleOwner.lifecycleScope.apply {
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.isReloading.collect { isReloading ->
+ binding.swipeRefresh.isRefreshing = isReloading
+ if (gamesViewModel.games.value.isEmpty() && !isReloading) {
+ binding.noticeText.visibility = View.VISIBLE
+ } else {
+ binding.noticeText.visibility = View.INVISIBLE
+ }
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.games.collectLatest { setAdapter(it) }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.shouldSwapData.collect {
+ if (it) {
+ setAdapter(gamesViewModel.games.value)
+ gamesViewModel.setShouldSwapData(false)
+ }
+ }
+ }
+ }
+ launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ gamesViewModel.shouldScrollToTop.collect {
+ if (it) {
+ scrollToTop()
+ gamesViewModel.setShouldScrollToTop(false)
+ }
+ }
+ }
+ }
+ }
+ setInsets()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+
+ private fun setAdapter(games: List) {
+ val preferences =
+ PreferenceManager.getDefaultSharedPreferences(SkyEmuApplication.appContext)
+ (binding.gridGames.adapter as GameAdapter).submitList(games)
+ }
+
+ private fun scrollToTop() {
+ if (_binding != null) {
+ binding.gridGames.smoothScrollToPosition(0)
+ }
+ }
+
+ private fun setInsets() =
+ ViewCompat.setOnApplyWindowInsetsListener(
+ binding.root
+ ) { view: View, windowInsets: WindowInsetsCompat ->
+ val barInsets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
+ val cutoutInsets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout())
+ val extraListSpacing = resources.getDimensionPixelSize(R.dimen.spacing_large)
+ val spacingNavigation = resources.getDimensionPixelSize(R.dimen.spacing_navigation)
+ val spacingNavigationRail =
+ resources.getDimensionPixelSize(R.dimen.spacing_navigation_rail)
+
+ binding.gridGames.updatePadding(
+ top = barInsets.top + extraListSpacing,
+ bottom = barInsets.bottom + spacingNavigation + extraListSpacing
+ )
+
+ binding.swipeRefresh.setProgressViewEndTarget(
+ false,
+ barInsets.top + resources.getDimensionPixelSize(R.dimen.spacing_refresh_end)
+ )
+
+ val leftInsets = barInsets.left + cutoutInsets.left
+ val rightInsets = barInsets.right + cutoutInsets.right
+ val mlpSwipe = binding.coordinatorMain.layoutParams as MarginLayoutParams
+ if (view.layoutDirection == View.LAYOUT_DIRECTION_LTR) {
+ mlpSwipe.leftMargin = leftInsets + spacingNavigationRail
+ mlpSwipe.rightMargin = rightInsets
+ } else {
+ mlpSwipe.leftMargin = leftInsets
+ mlpSwipe.rightMargin = rightInsets + spacingNavigationRail
+ }
+ binding.coordinatorMain.layoutParams = mlpSwipe
+
+ val fab = binding.add.layoutParams as MarginLayoutParams
+ val fabPadding = requireActivity().resources.getDimensionPixelSize(R.dimen.spacing_large)
+ fab.leftMargin = leftInsets + fabPadding
+ fab.bottomMargin = barInsets.bottom + fabPadding
+ fab.rightMargin = rightInsets + fabPadding
+ binding.add.layoutParams = fab
+
+ binding.noticeText.updatePadding(bottom = spacingNavigation)
+
+ windowInsets
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/models/Game.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/models/Game.kt
new file mode 100644
index 000000000..3e92bcdc9
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/models/Game.kt
@@ -0,0 +1,28 @@
+
+package com.sky.SkyEmu.models
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Parcelable
+import com.sky.SkyEmu.EnhancedNativeActivity
+import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.Serializable
+import java.util.HashSet
+
+@Parcelize
+@Serializable
+class Game(
+ val title: String = "",
+ val description: String = "",
+ val path: String,
+ val icon: IntArray? = null,
+ val filename: String
+) : Parcelable {
+ companion object {
+ val supportedExtensions: Set get() = extensions
+
+ val extensions: Set = HashSet(
+ listOf("gb", "gba", "zip", "nds")
+ )
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/utils/FileUtil.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/utils/FileUtil.kt
new file mode 100644
index 000000000..50b5ed31d
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/utils/FileUtil.kt
@@ -0,0 +1,137 @@
+
+package com.sky.SkyEmu.utils
+
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.util.Log
+import android.provider.DocumentsContract
+import android.system.Os
+import android.util.Pair
+import androidx.documentfile.provider.DocumentFile
+import com.sky.SkyEmu.SkyEmuApplication
+import java.io.BufferedInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.URLDecoder
+import java.nio.charset.StandardCharsets
+
+object FileUtil {
+ val context: Context get() = SkyEmuApplication.appContext
+
+
+ /**
+ * Check whether given path exists.
+ *
+ * @param path Native content uri path
+ * @return bool
+ */
+ @JvmStatic
+ fun exists(path: String): Boolean {
+ var c: Cursor? = null
+ try {
+ val uri = Uri.parse(path)
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
+ c = context.contentResolver.query(
+ uri,
+ columns,
+ null,
+ null,
+ null
+ )
+ return c!!.count > 0
+ } catch (e: Exception) {
+ Log.i("FileUtil", "Cannot find file from given path, error: " + e.message)
+ } finally {
+ // do nothing
+ }
+ return false
+ }
+
+ /**
+ * Check whether given path is a directory
+ *
+ * @param path content uri path
+ * @return bool
+ */
+ @JvmStatic
+ fun isDirectory(path: String): Boolean {
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE)
+ var isDirectory = false
+ var c: Cursor? = null
+ try {
+ val uri = Uri.parse(path)
+ c = context.contentResolver.query(uri, columns, null, null, null)
+ c!!.moveToNext()
+ val mimeType = c.getString(0)
+ isDirectory = mimeType == DocumentsContract.Document.MIME_TYPE_DIR
+ } catch (e: Exception) {
+ Log.e("FileUtil", "Cannot list files, error: " + e.message)
+ } finally {
+ // do nothing
+ }
+ return isDirectory
+ }
+
+ /**
+ * Get file display name from given path
+ *
+ * @param uri content uri
+ * @return String display name
+ */
+ @JvmStatic
+ fun getFilename(uri: Uri): String {
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
+ var filename = ""
+ var c: Cursor? = null
+ try {
+ c = context.contentResolver.query(
+ uri,
+ columns,
+ null,
+ null,
+ null
+ )
+ c!!.moveToNext()
+ filename = c.getString(0)
+ } catch (e: Exception) {
+ Log.e("FileUtil", "Cannot get file name, error: " + e.message)
+ } finally {
+ // do nothing
+ }
+ return filename
+ }
+
+ /**
+ * Get file size from given path.
+ *
+ * @param path content uri path
+ * @return long file size
+ */
+ @JvmStatic
+ fun getFileSize(path: String): Long {
+ val columns = arrayOf(DocumentsContract.Document.COLUMN_SIZE)
+ var size: Long = 0
+ var c: Cursor? = null
+ try {
+ val uri = Uri.parse(path)
+ c = context.contentResolver.query(
+ uri,
+ columns,
+ null,
+ null,
+ null
+ )
+ c!!.moveToNext()
+ size = c.getLong(0)
+ } catch (e: Exception) {
+ Log.e("FileUtil", "Cannot get file size, error: " + e.message)
+ } finally {
+ // do nothing
+ }
+ return size
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/utils/GameUtils.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/utils/GameUtils.kt
new file mode 100644
index 000000000..ca2dd7bbd
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/utils/GameUtils.kt
@@ -0,0 +1,86 @@
+
+package com.sky.SkyEmu.utils
+
+import android.content.SharedPreferences
+import android.content.ContentResolver
+import android.content.Context
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.preference.PreferenceManager
+import com.sky.SkyEmu.SkyEmuApplication
+import com.sky.SkyEmu.models.Game
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import java.io.IOException
+
+object GameUtils {
+ const val KEY_GAMES = "Games"
+
+ private lateinit var preferences: SharedPreferences
+
+ fun getGames(): List {
+ var games = mutableListOf()
+ val context = SkyEmuApplication.appContext
+ preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ val serializedGames = preferences.getStringSet(KEY_GAMES, emptySet()) ?: emptySet()
+ games = serializedGames.map { Json.decodeFromString(it) }.toMutableList()
+ return games.toList()
+ }
+
+ fun getGame(uri: Uri): Game {
+ val filePath = uri.toString()
+
+ val newGame = Game(
+ FileUtil.getFilename(uri).replace(
+ "[\\t\\n\\r]+".toRegex(),
+ " "
+ ),
+ filePath.replace("\n", " "),
+ uri.toString(),
+ null,
+ FileUtil.getFilename(Uri.parse(filePath))
+ )
+
+ return newGame
+ }
+
+ fun addGame(uri: Uri) : LoaderResult {
+ if (!isSupportedExtension(SkyEmuApplication.appContext, uri)) {
+ return LoaderResult.Error
+ }
+ preferences = PreferenceManager.getDefaultSharedPreferences(SkyEmuApplication.appContext)
+ val serializedGames = preferences.getStringSet(KEY_GAMES, emptySet()) ?: emptySet()
+ val games = serializedGames.map { Json.decodeFromString(it) }.toMutableList()
+ games.add(getGame(uri))
+ val newSerializedGames = mutableSetOf()
+ games.forEach { newSerializedGames.add(Json.encodeToString(it)) }
+
+ preferences.edit()
+ .remove(KEY_GAMES)
+ .putStringSet(KEY_GAMES, newSerializedGames)
+ .apply()
+
+ return LoaderResult.Success
+ }
+
+ private fun isSupportedExtension(context: Context, uri: Uri): Boolean {
+ val contentResolver: ContentResolver = context.contentResolver
+ val cursor = contentResolver.query(uri, null, null, null, null)
+
+ cursor?.use {
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (nameIndex != -1 && cursor.moveToFirst()) {
+ val fileName = cursor.getString(nameIndex) ?: return false
+ val fileExtension = fileName.substringAfterLast('.', "").lowercase()
+ return fileExtension in Game.supportedExtensions
+ }
+ }
+ return false
+ }
+}
+
+enum class LoaderResult(val code: Int) {
+ Success(0),
+ Error(1);
+}
diff --git a/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/viewmodels/GamesViewModel.kt b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/viewmodels/GamesViewModel.kt
new file mode 100644
index 000000000..5dca7e891
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/java/com/sky/SkyEmu/viewmodels/GamesViewModel.kt
@@ -0,0 +1,82 @@
+
+package com.sky.SkyEmu.viewmodels
+
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.preference.PreferenceManager
+import com.sky.SkyEmu.SkyEmuApplication
+import com.sky.SkyEmu.models.Game
+import com.sky.SkyEmu.utils.GameUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.json.Json
+import java.util.Locale
+
+class GamesViewModel : ViewModel() {
+ val games get() = _games.asStateFlow()
+ private val _games = MutableStateFlow(emptyList())
+
+ val searchedGames get() = _searchedGames.asStateFlow()
+ private val _searchedGames = MutableStateFlow(emptyList())
+
+ val isReloading get() = _isReloading.asStateFlow()
+ private val _isReloading = MutableStateFlow(false)
+
+ val shouldSwapData get() = _shouldSwapData.asStateFlow()
+ private val _shouldSwapData = MutableStateFlow(false)
+
+ val shouldScrollToTop get() = _shouldScrollToTop.asStateFlow()
+ private val _shouldScrollToTop = MutableStateFlow(false)
+
+ val searchFocused get() = _searchFocused.asStateFlow()
+ private val _searchFocused = MutableStateFlow(false)
+
+ init {
+ // Retrieve list of games
+ setGames(GameUtils.getGames())
+ reloadGames(false)
+ }
+
+ fun setGames(games: List) {
+ _games.value = games
+ }
+
+ fun setSearchedGames(games: List) {
+ _searchedGames.value = games
+ }
+
+ fun setShouldSwapData(shouldSwap: Boolean) {
+ _shouldSwapData.value = shouldSwap
+ }
+
+ fun setShouldScrollToTop(shouldScroll: Boolean) {
+ _shouldScrollToTop.value = shouldScroll
+ }
+
+ fun setSearchFocused(searchFocused: Boolean) {
+ _searchFocused.value = searchFocused
+ }
+
+ fun reloadGames(directoryChanged: Boolean) {
+ if (isReloading.value) {
+ return
+ }
+ _isReloading.value = true
+
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ setGames(GameUtils.getGames())
+ _isReloading.value = false
+
+ /*if (directoryChanged) {
+ setShouldSwapData(true)
+ }*/
+ }
+ }
+ }
+}
diff --git a/tools/android_project_kotlin/app/src/main/res/drawable/default_icon.jpg b/tools/android_project_kotlin/app/src/main/res/drawable/default_icon.jpg
new file mode 100644
index 000000000..902eca26e
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/drawable/default_icon.jpg differ
diff --git a/tools/android_project_kotlin/app/src/main/res/drawable/ic_add.xml b/tools/android_project_kotlin/app/src/main/res/drawable/ic_add.xml
new file mode 100644
index 000000000..d6fd3d379
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/drawable/ic_add.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/drawable/ic_controller.xml b/tools/android_project_kotlin/app/src/main/res/drawable/ic_controller.xml
new file mode 100644
index 000000000..ca404b929
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/drawable/ic_controller.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/drawable/ic_search.xml b/tools/android_project_kotlin/app/src/main/res/drawable/ic_search.xml
new file mode 100644
index 000000000..390774bbc
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/drawable/ic_search.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/drawable/ic_settings.xml b/tools/android_project_kotlin/app/src/main/res/drawable/ic_settings.xml
new file mode 100644
index 000000000..bc7c26370
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/drawable/ic_settings.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/drawable/rounded_selectable_item_background.xml b/tools/android_project_kotlin/app/src/main/res/drawable/rounded_selectable_item_background.xml
new file mode 100644
index 000000000..f1ae79cbf
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/drawable/rounded_selectable_item_background.xml
@@ -0,0 +1,9 @@
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/ic_launcher-web.png b/tools/android_project_kotlin/app/src/main/res/ic_launcher-web.png
new file mode 100644
index 000000000..b05ccd2c6
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/ic_launcher-web.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/layout/app_item.xml b/tools/android_project_kotlin/app/src/main/res/layout/app_item.xml
new file mode 100644
index 000000000..e950ef7df
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/layout/app_item.xml
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/layout/fragment_games.xml b/tools/android_project_kotlin/app/src/main/res/layout/fragment_games.xml
new file mode 100644
index 000000000..6b59efa5b
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/layout/fragment_games.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/layout/main_activity.xml b/tools/android_project_kotlin/app/src/main/res/layout/main_activity.xml
new file mode 100644
index 000000000..4cbb7e0e3
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/layout/main_activity.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/layout/material_switch.xml b/tools/android_project_kotlin/app/src/main/res/layout/material_switch.xml
new file mode 100644
index 000000000..a18e2a48c
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/layout/material_switch.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/menu/menu_navigation.xml b/tools/android_project_kotlin/app/src/main/res/menu/menu_navigation.xml
new file mode 100644
index 000000000..9cc20c7ff
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/menu/menu_navigation.xml
@@ -0,0 +1,18 @@
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/tools/android_project_kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..036d09bc5
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/tools/android_project_kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..036d09bc5
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher.png b/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..305557bf1
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..ccc31981e
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..13bc69caf
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-ldpi/ic_launcher.png b/tools/android_project_kotlin/app/src/main/res/mipmap-ldpi/ic_launcher.png
new file mode 100644
index 000000000..cc5f61f40
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-ldpi/ic_launcher.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher.png b/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..e43f037fa
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..45f1e2764
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..e7a029c55
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..f1eddf220
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..cf713f9a9
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..1d760a577
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..92514e785
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..422c242d8
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..73e55bc7e
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..eb0775609
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..3b5958b03
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..b5ad8391d
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/navigation/home_navigation.xml b/tools/android_project_kotlin/app/src/main/res/navigation/home_navigation.xml
new file mode 100644
index 000000000..06c3a7419
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/navigation/home_navigation.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/android_project_kotlin/app/src/main/res/playstore-icon.png b/tools/android_project_kotlin/app/src/main/res/playstore-icon.png
new file mode 100644
index 000000000..e67a0a7eb
Binary files /dev/null and b/tools/android_project_kotlin/app/src/main/res/playstore-icon.png differ
diff --git a/tools/android_project_kotlin/app/src/main/res/values-night-v31/themes.xml b/tools/android_project_kotlin/app/src/main/res/values-night-v31/themes.xml
new file mode 100644
index 000000000..d81be1ca7
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values-night-v31/themes.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/values-night/colors.xml b/tools/android_project_kotlin/app/src/main/res/values-night/colors.xml
new file mode 100644
index 000000000..39402be2b
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,35 @@
+
+
+
+ #B7C4FF
+ #002681
+ #0039B5
+ #DCE1FF
+ #C2C5DD
+ #2B3042
+ #424659
+ #DEE1F9
+ #E4BADA
+ #43273F
+ #5C3D56
+ #FFD7F5
+ #FFB4AB
+ #93000A
+ #690005
+ #FFDAD6
+ #1B1B1F
+ #E4E1E6
+ #1B1B1F
+ #E4E1E6
+ #45464F
+ #C6C5D0
+ #90909A
+ #1B1B1F
+ #E4E1E6
+ #154FE2
+ #000000
+ #B7C4FF
+ #45464F
+ #000000
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/values-night/themes.xml b/tools/android_project_kotlin/app/src/main/res/values-night/themes.xml
new file mode 100644
index 000000000..72054d504
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/values-v31/themes.xml b/tools/android_project_kotlin/app/src/main/res/values-v31/themes.xml
new file mode 100644
index 000000000..9ec881286
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values-v31/themes.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/values/colors.xml b/tools/android_project_kotlin/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..976d53d47
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values/colors.xml
@@ -0,0 +1,35 @@
+
+
+
+ #154FE2
+ #FFFFFF
+ #DCE1FF
+ #001551
+ #595D72
+ #FFFFFF
+ #DEE1F9
+ #161B2C
+ #75546F
+ #FFFFFF
+ #FFD7F5
+ #2C1229
+ #BA1A1A
+ #FFDAD6
+ #FFFFFF
+ #410002
+ #FEFBFF
+ #1B1B1F
+ #FEFBFF
+ #1B1B1F
+ #E2E1EC
+ #45464F
+ #767680
+ #F2F0F4
+ #303034
+ #B7C4FF
+ #000000
+ #154FE2
+ #C6C5D0
+ #000000
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/values/dimens.xml b/tools/android_project_kotlin/app/src/main/res/values/dimens.xml
new file mode 100644
index 000000000..8ec30b4b2
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values/dimens.xml
@@ -0,0 +1,19 @@
+
+
+ 16dp
+ 18dp
+
+ 4dp
+ 8dp
+ 12dp
+ 16dp
+ 80dp
+ 80dp
+ 0dp
+ 72dp
+ 20dp
+ 72dp
+ 24dp
+
+ 20dp
+
diff --git a/tools/android_project_kotlin/app/src/main/res/values/ic_launcher_background.xml b/tools/android_project_kotlin/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 000000000..04daf86ae
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #6f8a91
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/values/strings.xml b/tools/android_project_kotlin/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..c202418c1
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ SkyEmu
+
diff --git a/tools/android_project_kotlin/app/src/main/res/values/themes.xml b/tools/android_project_kotlin/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..1766e6006
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/values/themes.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tools/android_project_kotlin/app/src/main/res/xml/provider_paths.xml b/tools/android_project_kotlin/app/src/main/res/xml/provider_paths.xml
new file mode 100644
index 000000000..c69abbbd9
--- /dev/null
+++ b/tools/android_project_kotlin/app/src/main/res/xml/provider_paths.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/tools/android_project_kotlin/build.gradle.kts b/tools/android_project_kotlin/build.gradle.kts
new file mode 100644
index 000000000..c616d4b31
--- /dev/null
+++ b/tools/android_project_kotlin/build.gradle.kts
@@ -0,0 +1,20 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id("com.android.application") version "8.7.0" apply false
+ id("com.android.library") version "8.7.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
+}
+
+tasks.register("clean").configure {
+ delete(rootProject.layout.buildDirectory)
+}
+
+buildscript {
+ repositories {
+ google()
+ }
+ dependencies {
+ classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.8.4")
+ }
+}
diff --git a/tools/android_project_kotlin/gradle.properties b/tools/android_project_kotlin/gradle.properties
new file mode 100644
index 000000000..14697d21d
--- /dev/null
+++ b/tools/android_project_kotlin/gradle.properties
@@ -0,0 +1,20 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+android.enableJetifier=true
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=false
+
diff --git a/tools/android_project_kotlin/gradle/wrapper/gradle-wrapper.jar b/tools/android_project_kotlin/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..f3d88b1c2
Binary files /dev/null and b/tools/android_project_kotlin/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/tools/android_project_kotlin/gradle/wrapper/gradle-wrapper.properties b/tools/android_project_kotlin/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..81aa1c044
--- /dev/null
+++ b/tools/android_project_kotlin/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/tools/android_project_kotlin/gradlew b/tools/android_project_kotlin/gradlew
new file mode 100644
index 000000000..2fe81a7d9
--- /dev/null
+++ b/tools/android_project_kotlin/gradlew
@@ -0,0 +1,183 @@
+#!/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=`expr $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"
+
+exec "$JAVACMD" "$@"
diff --git a/tools/android_project_kotlin/gradlew.bat b/tools/android_project_kotlin/gradlew.bat
new file mode 100644
index 000000000..24467a141
--- /dev/null
+++ b/tools/android_project_kotlin/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/tools/android_project_kotlin/settings.gradle.kts b/tools/android_project_kotlin/settings.gradle.kts
new file mode 100644
index 000000000..1fb82df41
--- /dev/null
+++ b/tools/android_project_kotlin/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+
+@Suppress("UnstableApiUsage")
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+include(":app")
diff --git a/tools/android_project_kotlin/skyemu-open-signing-store b/tools/android_project_kotlin/skyemu-open-signing-store
new file mode 100644
index 000000000..aa08db074
Binary files /dev/null and b/tools/android_project_kotlin/skyemu-open-signing-store differ