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 +----------- +![screenshot](screenshot.png) + +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