Initial commit
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(find . -maxdepth 4 -not -path '*/\\\\.*')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -0,0 +1 @@
|
||||
jnicpp
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetSelector">
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DeviceTable">
|
||||
<option name="columnSorters">
|
||||
<list>
|
||||
<ColumnSorterState>
|
||||
<option name="column" value="Name" />
|
||||
<option name="order" value="ASCENDING" />
|
||||
</ColumnSorterState>
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,71 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'com.example.jnicpp'
|
||||
compileSdk {
|
||||
version = release(36) {
|
||||
minorApiLevel = 1
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.example.jnicpp"
|
||||
minSdk 24
|
||||
targetSdk 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file('src/main/cpp/CMakeLists.txt')
|
||||
version '3.22.1'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation libs.androidx.core.ktx
|
||||
implementation libs.androidx.appcompat
|
||||
implementation libs.material
|
||||
implementation libs.androidx.constraintlayout
|
||||
|
||||
// CameraX
|
||||
implementation libs.androidx.camera.core
|
||||
implementation libs.androidx.camera.camera2
|
||||
implementation libs.androidx.camera.lifecycle
|
||||
implementation libs.androidx.camera.video
|
||||
implementation libs.androidx.camera.view
|
||||
implementation libs.androidx.camera.effects
|
||||
|
||||
// Lifecycle / ViewModel
|
||||
implementation libs.androidx.lifecycle.viewmodel.ktx
|
||||
implementation libs.androidx.lifecycle.runtime.ktx
|
||||
implementation libs.androidx.activity.ktx
|
||||
|
||||
// ML Kit Pose Detection (accurate model, for form analysis precision)
|
||||
implementation libs.mlkit.pose.detection.accurate
|
||||
|
||||
implementation libs.kotlinx.coroutines.android
|
||||
|
||||
testImplementation libs.junit
|
||||
androidTestImplementation libs.androidx.junit
|
||||
androidTestImplementation libs.androidx.espresso.core
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.jnicpp
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.example.jnicpp", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- OPENGL ES 3.0 Requirement -->
|
||||
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
|
||||
|
||||
<!-- Bowling form analysis: camera capture + audio track for recorded video -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- Only needed to write into the shared Movies collection on pre-scoped-storage devices (API <= 28) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Jnicpp">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Standalone entry point for now (launched directly, e.g. via adb or a
|
||||
launcher shortcut) until this is wired into the game's menu/state
|
||||
system. Orientation follows the device sensor (see layout-land/
|
||||
for the landscape button arrangement vs. the default portrait one)
|
||||
rather than being locked, so the buttons reposition as the phone
|
||||
is rotated.
|
||||
-->
|
||||
<activity
|
||||
android:name=".bowling.BowlingCameraActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="unspecified"
|
||||
android:theme="@style/Theme.Jnicpp.Camera" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,50 @@
|
||||
# For more information about using CMake with Android Studio, read the
|
||||
# documentation: https://d.android.com/studio/projects/add-native-code.html.
|
||||
# For more examples on how to use CMake, see https://github.com/android/ndk-samples.
|
||||
# Sets the minimum CMake version required for this project.
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
# Set C++ standard
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
# Declares the project name. The project name can be accessed via ${ PROJECT_NAME},
|
||||
# Since this is the top level CMakeLists.txt, the project name is also accessible
|
||||
# with ${CMAKE_PROJECT_NAME} (both CMake variables are in-sync within the top level
|
||||
# build script scope).
|
||||
|
||||
project("jnicpp")
|
||||
# Creates and names a library, sets it as either STATIC
|
||||
# or SHARED, and provides the relative paths to its source code.
|
||||
# You can define multiple libraries, and CMake builds them for you.
|
||||
# Gradle automatically packages shared libraries with your APK.
|
||||
#
|
||||
# In this top level CMakeLists.txt, ${CMAKE_PROJECT_NAME} is used to define
|
||||
# the target library name; in the sub-module's CMakeLists.txt, ${PROJECT_NAME}
|
||||
# is preferred for the same purpose.
|
||||
#
|
||||
# In order to load a library into your app from Java/Kotlin, you must call
|
||||
# System.loadLibrary() and pass the name of the library defined here;
|
||||
# for GameActivity/NativeActivity derived applications, the same library name must be
|
||||
# used in the AndroidManifest.xml file.
|
||||
set(SRC_DIR ../../../../../my_gl_app)
|
||||
# Get source files from my_gl_app directory
|
||||
file(GLOB_RECURSE MY_GL_APP_SRC CONFIGURE_DEPENDS
|
||||
"${SRC_DIR}/*.cpp"
|
||||
"${SRC_DIR}/*.h"
|
||||
"${SRC_DIR}/*.hpp"
|
||||
)
|
||||
# Include directories
|
||||
include_directories(${SRC_DIR})
|
||||
include_directories(${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
# List C/C++ source files with relative paths to this CMakeLists.txt.
|
||||
NativeTemplate.cpp
|
||||
${MY_GL_APP_SRC})
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
# can link libraries from various origins, such as libraries defined in this
|
||||
# build script, prebuilt third-party libraries, or Android system libraries.
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
# List libraries link to the target library
|
||||
android
|
||||
log
|
||||
GLESv3
|
||||
EGL)
|
||||
@@ -0,0 +1,105 @@
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
#include <android/log.h>
|
||||
// Ensure JNI macros are available
|
||||
#ifndef JNIEXPORT
|
||||
|
||||
#define JNIEXPORT
|
||||
#endif
|
||||
#ifndef JNICALL
|
||||
#define JNICALL
|
||||
#endif
|
||||
#include "GLRenderer.h"
|
||||
#include "UIRenderer.h"
|
||||
#include "PlatformBridge.h"
|
||||
// Define logging macros for this file
|
||||
#ifndef LOG_TAG
|
||||
#define LOG_TAG "NativeTemplate"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
||||
#endif
|
||||
// Global renderer instance
|
||||
static GLRenderer* g_renderer = nullptr;
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_com_example_jnicpp_MainActivity_stringFromJNI(
|
||||
JNIEnv* env,
|
||||
jobject /* this */) {
|
||||
LOGI("stringFromJNI called");
|
||||
std::string hello = "OpenGL ES 3.0 Triangle Demo";
|
||||
return env->NewStringUTF(hello.c_str());
|
||||
}
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_example_jnicpp_MainActivity_initGL(
|
||||
JNIEnv* env,
|
||||
jobject thiz) {
|
||||
LOGI("initGL called");
|
||||
// Cache a global ref to the activity so native code (e.g. Menu3State,
|
||||
// running on this same GL thread) can call back into Java later, e.g. to
|
||||
// launch BowlingCameraActivity. See PlatformBridge.h.
|
||||
PlatformBridge::SetAndroidActivity(env, thiz);
|
||||
// Create renderer if not exists
|
||||
if (g_renderer == nullptr) {
|
||||
LOGI("Creating new GLRenderer instance");
|
||||
g_renderer = new GLRenderer();
|
||||
if (g_renderer == nullptr) {
|
||||
LOGE("Failed to create GLRenderer instance");
|
||||
return JNI_FALSE;
|
||||
}
|
||||
}
|
||||
// Initialize OpenGL renderer (no longer needs surface parameter)
|
||||
bool success = g_renderer->initialize();
|
||||
if (success) {
|
||||
LOGI("OpenGL initialization successful");
|
||||
} else {
|
||||
LOGE("OpenGL initialization failed");
|
||||
}
|
||||
|
||||
return success ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_example_jnicpp_MainActivity_renderFrame(
|
||||
JNIEnv* env,
|
||||
jobject /* this */) {
|
||||
if (g_renderer != nullptr) {
|
||||
g_renderer->render();
|
||||
} else {
|
||||
LOGE("Renderer is null in renderFrame");
|
||||
}
|
||||
}
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_example_jnicpp_MainActivity_onSurfaceResized(
|
||||
JNIEnv* env,
|
||||
jobject /* this */,
|
||||
jint width,
|
||||
jint height) {
|
||||
LOGI("onSurfaceResized called: %dx%d", width, height);
|
||||
glViewport(0, 0, width, height);
|
||||
UI::Init(width, height); // no-op besides updating cached size if already initialized
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_example_jnicpp_MainActivity_nativeOnTouch(
|
||||
JNIEnv* env,
|
||||
jobject /* this */,
|
||||
jfloat x,
|
||||
jfloat y,
|
||||
jboolean isDown) {
|
||||
UI::SetPointerPosition(x, y);
|
||||
UI::SetPointerDown(isDown == JNI_TRUE);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_example_jnicpp_MainActivity_cleanupGL(
|
||||
JNIEnv* env,
|
||||
jobject /* this */) {
|
||||
LOGI("cleanupGL called");
|
||||
if (g_renderer != nullptr) {
|
||||
g_renderer->cleanup();
|
||||
delete g_renderer;
|
||||
g_renderer = nullptr;
|
||||
LOGI("GLRenderer cleaned up and deleted");
|
||||
} else {
|
||||
LOGI("GLRenderer was already null");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.example.jnicpp;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import android.content.Intent;
|
||||
import android.opengl.GLSurfaceView;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
import com.example.jnicpp.bowling.BowlingCameraActivity;
|
||||
import com.example.jnicpp.databinding.ActivityMainBinding;
|
||||
import javax.microedition.khronos.egl.EGLConfig;
|
||||
import javax.microedition.khronos.opengles.GL10;
|
||||
public class MainActivity extends AppCompatActivity implements GLSurfaceView.Renderer {
|
||||
private static final String TAG = "MainActivity";
|
||||
// Used to load the 'jnicpp' library on application startup.
|
||||
static {
|
||||
try {
|
||||
System.loadLibrary("jnicpp");
|
||||
Log.d(TAG, "Native library loaded successfully");
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
Log.e(TAG, "Failed to load native library", e);
|
||||
}
|
||||
}
|
||||
private ActivityMainBinding binding;
|
||||
private GLSurfaceView glSurfaceView;
|
||||
private boolean isInitialized = false;
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Log.d(TAG, "onCreate called");
|
||||
try {
|
||||
binding = ActivityMainBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
// Setup GLSurfaceView
|
||||
glSurfaceView = binding.glSurfaceView;
|
||||
glSurfaceView.setEGLContextClientVersion(3); // OpenGL ES 3.0
|
||||
// Without this, launching an opaque Activity on top (e.g.
|
||||
// BowlingCameraActivity) destroys the GLSurfaceView's Surface, which tears
|
||||
// down the EGL context too. On return, a new context gets created and
|
||||
// initGL() runs again, but UIRenderer's one-time GL object setup
|
||||
// (UI::Init()'s s_initialized guard) doesn't know the old context - and its
|
||||
// GL handles - are gone, so it skips recreating them and the menu UI stops
|
||||
// drawing (leaving only the background demo triangle visible). Preserving
|
||||
// the context avoids that teardown/recreate cycle entirely.
|
||||
glSurfaceView.setPreserveEGLContextOnPause(true);
|
||||
glSurfaceView.setRenderer(this);
|
||||
glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
|
||||
// Touch listener attached directly to the GLSurfaceView (rather than
|
||||
// overriding Activity.onTouchEvent) so MotionEvent.getX()/getY() are
|
||||
// guaranteed relative to this view's own bounds - the same pixel space
|
||||
// our GL rendering and UI hit-testing use. Activity-level touch
|
||||
// coordinates aren't guaranteed to line up with a child view's
|
||||
// coordinates in every layout, which was causing clicks to land on the
|
||||
// wrong button.
|
||||
glSurfaceView.setOnTouchListener(new View.OnTouchListener() {
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
int action = event.getActionMasked();
|
||||
boolean isDown = (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_MOVE);
|
||||
nativeOnTouch(event.getX(), event.getY(), isDown);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
// Example of a call to a native method
|
||||
String message = stringFromJNI();
|
||||
Log.d(TAG, "Native message: " + message);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error in onCreate", e);
|
||||
showToast("Error initializing app: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@Override
|
||||
protected void onPause() {
|
||||
super.onPause();
|
||||
Log.d(TAG, "onPause called");
|
||||
if (glSurfaceView != null) {
|
||||
glSurfaceView.onPause();
|
||||
}
|
||||
}
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
Log.d(TAG, "onResume called");
|
||||
if (glSurfaceView != null) {
|
||||
glSurfaceView.onResume();
|
||||
}
|
||||
}
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
Log.d(TAG, "onDestroy called");
|
||||
if (isInitialized) {
|
||||
try {
|
||||
cleanupGL();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error during cleanup", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Helper method to show toast from any thread
|
||||
private void showToast(final String message) {
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
Toast.makeText(MainActivity.this, message, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Called from native code (see PlatformBridge::LaunchBowlingCamera() in
|
||||
* my_gl_app/PlatformBridge.cpp) when the game state machine switches
|
||||
* into StateID::Menu3. Invoked via JNI from the GL render thread, so
|
||||
* this hops to the UI thread before touching Activity APIs, same as
|
||||
* showToast() above. Front/back camera switching and backing out happen
|
||||
* on BowlingCameraActivity itself (its SWITCH CAMERA and BACK buttons).
|
||||
*/
|
||||
public void launchBowlingCamera() {
|
||||
runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
startActivity(new Intent(MainActivity.this, BowlingCameraActivity.class));
|
||||
}
|
||||
});
|
||||
}
|
||||
// GLSurfaceView.Renderer implementation
|
||||
@Override
|
||||
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
|
||||
Log.d(TAG, "Surface created");
|
||||
try {
|
||||
// Initialize OpenGL in native code (no longer needs surface parameter)
|
||||
boolean success = initGL();
|
||||
if (success) {
|
||||
isInitialized = true;
|
||||
Log.d(TAG, "OpenGL ES 3.0 initialized successfully");
|
||||
} else {
|
||||
Log.e(TAG, "Failed to initialize OpenGL ES 3.0");
|
||||
showToast("Failed to initialize OpenGL ES 3.0");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error in onSurfaceCreated", e);
|
||||
showToast("Error creating surface: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onSurfaceChanged(GL10 gl, int width, int height) {
|
||||
Log.d(TAG, "Surface changed: " + width + "x" + height);
|
||||
onSurfaceResized(width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDrawFrame(GL10 gl) {
|
||||
if (isInitialized) {
|
||||
try {
|
||||
renderFrame();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error in onDrawFrame", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* A native method that is implemented by the 'jnicpp' native library,
|
||||
* which is packaged with this application.
|
||||
*/
|
||||
public native String stringFromJNI();
|
||||
/**
|
||||
* Initialize OpenGL ES 3.0 renderer
|
||||
*/
|
||||
public native boolean initGL();
|
||||
/**
|
||||
* Render a frame
|
||||
*/
|
||||
public native void renderFrame();
|
||||
/**
|
||||
* Cleanup OpenGL resources
|
||||
*/
|
||||
public native void cleanupGL();
|
||||
/**
|
||||
* Notify native code of the surface size, for UI layout/hit-testing
|
||||
*/
|
||||
public native void onSurfaceResized(int width, int height);
|
||||
/**
|
||||
* Forward a touch event to the native UI system
|
||||
*/
|
||||
public native void nativeOnTouch(float x, float y, boolean isDown);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.example.jnicpp.R
|
||||
import com.example.jnicpp.databinding.ActivityBowlingCameraBinding
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Screen that records video and runs real-time pose detection on the same
|
||||
* camera stream. This is a standalone entry point for now (see the plan
|
||||
* this was built from) -- it isn't wired into a menu/game-state system yet.
|
||||
*
|
||||
* This class intentionally does *not* contain any CameraX/ML Kit binding
|
||||
* logic itself -- that lives in [CameraXController] (use-case binding) and
|
||||
* [PoseAnalyzer] (per-frame inference), both plain classes that don't touch
|
||||
* Android component lifecycle. This class's job is just: own the executor,
|
||||
* own the ViewModel, wire user actions to the controller, and reflect
|
||||
* [CameraViewModel]'s state back onto the views.
|
||||
*/
|
||||
class BowlingCameraActivity : AppCompatActivity(), CameraXController.Callback {
|
||||
|
||||
/** Which button a recording in progress was started from. */
|
||||
private enum class RecordMode { VIDEO, POSE }
|
||||
|
||||
private lateinit var binding: ActivityBowlingCameraBinding
|
||||
private val viewModel: CameraViewModel by viewModels()
|
||||
|
||||
private lateinit var cameraExecutor: ExecutorService
|
||||
private lateinit var cameraXController: CameraXController
|
||||
private var lensFacing = CameraSelector.LENS_FACING_BACK
|
||||
private var activeRecordMode: RecordMode? = null
|
||||
|
||||
private val permissionLauncher =
|
||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ ->
|
||||
val granted = CameraPermissions.allGranted(this)
|
||||
viewModel.onPermissionsResult(granted)
|
||||
if (granted) {
|
||||
showCameraUi()
|
||||
startCamera()
|
||||
} else {
|
||||
showPermissionRationale(showAsDenied = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityBowlingCameraBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
cameraXController = CameraXController(applicationContext, cameraExecutor)
|
||||
|
||||
binding.btnGrantPermissions.setOnClickListener {
|
||||
permissionLauncher.launch(CameraPermissions.REQUIRED)
|
||||
}
|
||||
binding.btnRecordVideo.setOnClickListener { onRecordClicked(RecordMode.VIDEO) }
|
||||
binding.btnRecordWithPose.setOnClickListener { onRecordClicked(RecordMode.POSE) }
|
||||
binding.btnSwitchCamera.setOnClickListener { onSwitchCameraClicked() }
|
||||
binding.btnBack.setOnClickListener { finish() }
|
||||
|
||||
observeViewModel()
|
||||
|
||||
val granted = CameraPermissions.allGranted(this)
|
||||
viewModel.onPermissionsResult(granted)
|
||||
if (granted) {
|
||||
showCameraUi()
|
||||
startCamera()
|
||||
} else {
|
||||
showPermissionRationale(showAsDenied = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startCamera() {
|
||||
cameraXController.bindToLifecycle(
|
||||
lifecycleOwner = this,
|
||||
previewView = binding.cameraPreview,
|
||||
callback = this,
|
||||
lensFacing = lensFacing
|
||||
)
|
||||
}
|
||||
|
||||
private fun onRecordClicked(mode: RecordMode) {
|
||||
if (cameraXController.isRecording) {
|
||||
cameraXController.stopRecording()
|
||||
} else {
|
||||
activeRecordMode = mode
|
||||
val withPose = mode == RecordMode.POSE
|
||||
cameraXController.setPoseDetectionEnabled(withPose)
|
||||
if (!withPose) {
|
||||
// Clear any skeleton left over from a previous "with pose" recording.
|
||||
binding.poseOverlay.clear()
|
||||
}
|
||||
viewModel.onRecordingStarting()
|
||||
cameraXController.startRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onSwitchCameraClicked() {
|
||||
if (cameraXController.isRecording) {
|
||||
Toast.makeText(this, R.string.switch_camera_while_recording, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
lensFacing = if (lensFacing == CameraSelector.LENS_FACING_BACK) {
|
||||
CameraSelector.LENS_FACING_FRONT
|
||||
} else {
|
||||
CameraSelector.LENS_FACING_BACK
|
||||
}
|
||||
// CameraXController.bindToLifecycle() unbinds all use cases before
|
||||
// rebinding, so calling it again with the flipped lensFacing is
|
||||
// enough to switch cameras cleanly.
|
||||
if (CameraPermissions.allGranted(this)) {
|
||||
startCamera()
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeViewModel() {
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
launch {
|
||||
viewModel.recordingState.collect { state -> renderRecordingState(state) }
|
||||
}
|
||||
launch {
|
||||
viewModel.errorEvents.collect { message ->
|
||||
Toast.makeText(this@BowlingCameraActivity, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderRecordingState(state: CameraViewModel.RecordingState) {
|
||||
// The screen now rotates freely (see res/layout-land/), which
|
||||
// recreates this Activity on rotation - that would orphan an
|
||||
// in-progress recording, since the CameraXController instance
|
||||
// holding the active Recording gets torn down with it. Locking to
|
||||
// whichever orientation we're already in while Starting/Recording
|
||||
// (and releasing the lock once Idle again) keeps recordings from
|
||||
// being interrupted by a rotation mid-shot.
|
||||
requestedOrientation = if (state is CameraViewModel.RecordingState.Idle) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_LOCKED
|
||||
}
|
||||
when (state) {
|
||||
is CameraViewModel.RecordingState.Idle -> {
|
||||
activeRecordMode = null
|
||||
binding.layoutRecordingIndicator.visibility = View.GONE
|
||||
binding.btnRecordVideo.isEnabled = true
|
||||
binding.btnRecordVideo.setText(R.string.record_video)
|
||||
binding.btnRecordWithPose.isEnabled = true
|
||||
binding.btnRecordWithPose.setText(R.string.record_with_pose)
|
||||
}
|
||||
is CameraViewModel.RecordingState.Starting -> {
|
||||
// Neither button switches mode mid-flight, and neither can
|
||||
// stop a recording that hasn't started yet.
|
||||
binding.btnRecordVideo.isEnabled = false
|
||||
binding.btnRecordWithPose.isEnabled = false
|
||||
}
|
||||
is CameraViewModel.RecordingState.Recording -> {
|
||||
// Only the button that started this recording stays enabled
|
||||
// (now as the stop trigger); the other is disabled rather
|
||||
// than switching mode mid-recording.
|
||||
val videoActive = activeRecordMode == RecordMode.VIDEO
|
||||
binding.btnRecordVideo.isEnabled = videoActive
|
||||
binding.btnRecordVideo.setText(if (videoActive) R.string.stop_recording else R.string.record_video)
|
||||
binding.btnRecordWithPose.isEnabled = !videoActive
|
||||
binding.btnRecordWithPose.setText(if (!videoActive) R.string.stop_recording else R.string.record_with_pose)
|
||||
binding.layoutRecordingIndicator.visibility = View.VISIBLE
|
||||
val minutes = state.elapsedSeconds / 60
|
||||
val seconds = state.elapsedSeconds % 60
|
||||
binding.textTimer.text = String.format(Locale.US, "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCameraUi() {
|
||||
binding.layoutPermissionRationale.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showPermissionRationale(showAsDenied: Boolean) {
|
||||
binding.layoutPermissionRationale.visibility = View.VISIBLE
|
||||
binding.textPermissionMessage.setText(
|
||||
if (showAsDenied) R.string.permission_denied_message else R.string.permission_rationale_message
|
||||
)
|
||||
}
|
||||
|
||||
// ----- CameraXController.Callback -----
|
||||
|
||||
override fun onCameraReady() {
|
||||
showCameraUi()
|
||||
}
|
||||
|
||||
override fun onCameraError(message: String) {
|
||||
viewModel.postError(getString(R.string.error_camera_unavailable, message))
|
||||
}
|
||||
|
||||
override fun onRecordingStarted() {
|
||||
viewModel.onRecordingStarted()
|
||||
}
|
||||
|
||||
override fun onRecordingFinalized(outputUri: Uri) {
|
||||
viewModel.onRecordingStopped()
|
||||
Toast.makeText(this, outputUri.lastPathSegment ?: outputUri.toString(), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onRecordingError(message: String) {
|
||||
viewModel.postError(getString(R.string.error_recording_failed, message))
|
||||
}
|
||||
|
||||
override fun onPoseDetectorError(message: String) {
|
||||
// Detector hiccups on a single frame shouldn't interrupt an
|
||||
// in-progress recording -- just surface it, don't reset state.
|
||||
Toast.makeText(this, getString(R.string.error_pose_detector, message), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onPoseResult(result: PoseAnalyzer.PoseFrameResult) {
|
||||
// Safe to touch the view directly here: ML Kit's Task listeners
|
||||
// (see PoseAnalyzer) deliver on the main thread by default even
|
||||
// though inference itself runs on the background camera executor.
|
||||
binding.poseOverlay.update(result)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
cameraXController.release()
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Single source of truth for which runtime permissions this feature needs
|
||||
* and whether they're currently granted. The actual request flow (which
|
||||
* must be owned by an Activity/Fragment via ActivityResultContracts) lives
|
||||
* in [BowlingCameraActivity]; this object just centralizes the "what" so
|
||||
* both the manifest expectations and the request flow can't drift apart.
|
||||
*/
|
||||
object CameraPermissions {
|
||||
|
||||
val REQUIRED: Array<String> = buildList {
|
||||
add(Manifest.permission.CAMERA)
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
// Scoped storage (API 29+) lets an app insert into MediaStore's
|
||||
// shared Movies collection without this permission; below that, it's
|
||||
// required to save the recorded video into the gallery.
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
|
||||
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
fun allGranted(context: Context): Boolean =
|
||||
REQUIRED.all { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun missing(context: Context): List<String> =
|
||||
REQUIRED.filter { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Holds camera/recording UI state so it survives configuration changes and
|
||||
* so the state machine lives outside the Activity. This class knows nothing
|
||||
* about CameraX or ML Kit APIs directly -- [BowlingCameraActivity] and
|
||||
* [CameraXController] report events into it, and the UI observes it back
|
||||
* out. That keeps this class trivially unit-testable (no Android camera
|
||||
* framework involved).
|
||||
*/
|
||||
class CameraViewModel : ViewModel() {
|
||||
|
||||
sealed interface RecordingState {
|
||||
data object Idle : RecordingState
|
||||
data object Starting : RecordingState
|
||||
data class Recording(val elapsedSeconds: Long) : RecordingState
|
||||
}
|
||||
|
||||
private val _recordingState = MutableStateFlow<RecordingState>(RecordingState.Idle)
|
||||
val recordingState: StateFlow<RecordingState> = _recordingState.asStateFlow()
|
||||
|
||||
private val _permissionsGranted = MutableStateFlow(false)
|
||||
val permissionsGranted: StateFlow<Boolean> = _permissionsGranted.asStateFlow()
|
||||
|
||||
// One-shot user-facing error messages (camera unavailable, detector
|
||||
// failure, storage failure, ...). SharedFlow, not StateFlow, so the same
|
||||
// error doesn't get replayed and re-shown after a config change.
|
||||
private val _errorEvents = MutableSharedFlow<String>(extraBufferCapacity = 4)
|
||||
val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
|
||||
|
||||
private var timerJob: Job? = null
|
||||
|
||||
fun onPermissionsResult(granted: Boolean) {
|
||||
_permissionsGranted.value = granted
|
||||
}
|
||||
|
||||
fun onRecordingStarting() {
|
||||
_recordingState.value = RecordingState.Starting
|
||||
}
|
||||
|
||||
fun onRecordingStarted() {
|
||||
timerJob?.cancel()
|
||||
timerJob = viewModelScope.launch {
|
||||
var seconds = 0L
|
||||
while (isActive) {
|
||||
_recordingState.value = RecordingState.Recording(seconds)
|
||||
delay(1000)
|
||||
seconds++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onRecordingStopped() {
|
||||
timerJob?.cancel()
|
||||
timerJob = null
|
||||
_recordingState.value = RecordingState.Idle
|
||||
}
|
||||
|
||||
fun postError(message: String) {
|
||||
_errorEvents.tryEmit(message)
|
||||
// A failed start/stop shouldn't leave the UI stuck showing a
|
||||
// recording indicator that no longer reflects reality.
|
||||
if (_recordingState.value != RecordingState.Idle) {
|
||||
onRecordingStopped()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
timerJob?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.provider.MediaStore
|
||||
import androidx.camera.core.CameraEffect
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.UseCaseGroup
|
||||
import androidx.camera.effects.OverlayEffect
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FallbackStrategy
|
||||
import androidx.camera.video.MediaStoreOutputOptions
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.camera.video.Recording
|
||||
import androidx.camera.video.VideoCapture
|
||||
import androidx.camera.video.VideoRecordEvent
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.example.jnicpp.R
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Owns all CameraX use-case binding and recording control. Deliberately not
|
||||
* an Activity/Fragment/View: it only needs a [Context], a [LifecycleOwner]
|
||||
* and a couple of Android views handed to it, which keeps the CameraX wiring
|
||||
* isolated from Android component lifecycle boilerplate and easy to reason
|
||||
* about (and to fake out behind [Callback] in tests that don't need a real
|
||||
* camera).
|
||||
*/
|
||||
class CameraXController(
|
||||
private val appContext: Context,
|
||||
private val cameraExecutor: java.util.concurrent.Executor
|
||||
) {
|
||||
|
||||
interface Callback {
|
||||
fun onCameraReady() {}
|
||||
fun onCameraError(message: String)
|
||||
fun onRecordingStarted() {}
|
||||
fun onRecordingFinalized(outputUri: Uri) {}
|
||||
fun onRecordingError(message: String)
|
||||
fun onPoseDetectorError(message: String) {}
|
||||
fun onPoseResult(result: PoseAnalyzer.PoseFrameResult)
|
||||
}
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var videoCapture: VideoCapture<Recorder>? = null
|
||||
private var imageAnalysis: ImageAnalysis? = null
|
||||
private var poseAnalyzer: PoseAnalyzer? = null
|
||||
private var activeRecording: Recording? = null
|
||||
private var currentLensFacing: Int = CameraSelector.LENS_FACING_BACK
|
||||
private var callback: Callback? = null
|
||||
|
||||
// Whether the pose analyzer should be attached to the analysis stream,
|
||||
// and whether the skeleton should be composited into recorded frames.
|
||||
// Survives across bindToLifecycle() calls (e.g. switching cameras) so
|
||||
// the mode chosen via setPoseDetectionEnabled() isn't lost on rebind.
|
||||
private var poseDetectionEnabled = false
|
||||
|
||||
// Latest pose result, consumed by the OverlayEffect draw listener (see
|
||||
// below) to composite the skeleton into recorded video frames. Read on
|
||||
// the effect's GL/handler thread, written on the main thread from
|
||||
// PoseAnalyzer's callback -- both just replace the whole reference, so
|
||||
// no extra locking is needed.
|
||||
@Volatile
|
||||
private var latestPoseFrame: PoseAnalyzer.PoseFrameResult? = null
|
||||
|
||||
// Bakes the skeleton into VIDEO_CAPTURE output only (not PREVIEW) --
|
||||
// the live preview keeps using PoseOverlayView, which is already proven
|
||||
// to draw correctly; this only needs to affect what actually gets
|
||||
// encoded into the saved file.
|
||||
private var overlayEffect: OverlayEffect? = null
|
||||
private var overlayHandlerThread: HandlerThread? = null
|
||||
private val overlayBonePaint by lazy {
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(appContext, R.color.skeleton_bone)
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = PoseSkeletonRenderer.STROKE_WIDTH
|
||||
}
|
||||
}
|
||||
private val overlayJointPaint by lazy {
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(appContext, R.color.skeleton_joint)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
}
|
||||
|
||||
val isRecording: Boolean
|
||||
get() = activeRecording != null
|
||||
|
||||
/**
|
||||
* Binds Preview + VideoCapture (Recorder) + ImageAnalysis to a single
|
||||
* camera lifecycle, all at once, so the same camera stream feeds the
|
||||
* on-screen preview, the video file being recorded, and the pose
|
||||
* detector simultaneously. Also attaches an [OverlayEffect] to the
|
||||
* VideoCapture output so [setPoseDetectionEnabled] can bake the
|
||||
* skeleton into the recorded file, not just the live preview.
|
||||
*/
|
||||
fun bindToLifecycle(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
previewView: PreviewView,
|
||||
callback: Callback,
|
||||
lensFacing: Int = CameraSelector.LENS_FACING_BACK
|
||||
) {
|
||||
this.callback = callback
|
||||
this.currentLensFacing = lensFacing
|
||||
|
||||
val providerFuture = ProcessCameraProvider.getInstance(appContext)
|
||||
providerFuture.addListener({
|
||||
try {
|
||||
val provider = providerFuture.get()
|
||||
cameraProvider = provider
|
||||
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = previewView.surfaceProvider
|
||||
}
|
||||
|
||||
val recorder = Recorder.Builder()
|
||||
.setQualitySelector(
|
||||
QualitySelector.from(
|
||||
Quality.FHD,
|
||||
FallbackStrategy.higherQualityOrLowerThan(Quality.SD)
|
||||
)
|
||||
)
|
||||
.build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
this.videoCapture = videoCapture
|
||||
|
||||
poseAnalyzer?.close()
|
||||
val analyzer = PoseAnalyzer(
|
||||
isFrontCamera = { currentLensFacing == CameraSelector.LENS_FACING_FRONT },
|
||||
onResult = { result ->
|
||||
latestPoseFrame = result
|
||||
callback.onPoseResult(result)
|
||||
},
|
||||
onError = { e -> callback.onPoseDetectorError(e.message ?: "Pose detector error") }
|
||||
)
|
||||
poseAnalyzer = analyzer
|
||||
|
||||
// Analyzer is deliberately not attached here -- whether it's
|
||||
// attached at all is controlled by setPoseDetectionEnabled()
|
||||
// below, so the analysis stream stays idle (no inference
|
||||
// cost) whenever pose detection isn't the active mode.
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
// We only ever care about the freshest frame; if the
|
||||
// detector falls behind, drop stale frames instead of
|
||||
// queueing them, so pose overlay latency can't grow
|
||||
// unbounded relative to what's on screen.
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
this.imageAnalysis = imageAnalysis
|
||||
|
||||
val cameraSelector = CameraSelector.Builder()
|
||||
.requireLensFacing(lensFacing)
|
||||
.build()
|
||||
|
||||
val useCaseGroup = UseCaseGroup.Builder()
|
||||
.addUseCase(preview)
|
||||
.addUseCase(videoCapture)
|
||||
.addUseCase(imageAnalysis)
|
||||
.addEffect(getOrCreateOverlayEffect())
|
||||
.build()
|
||||
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(lifecycleOwner, cameraSelector, useCaseGroup)
|
||||
applyPoseDetectionEnabled()
|
||||
callback.onCameraReady()
|
||||
} catch (e: Exception) {
|
||||
callback.onCameraError(e.message ?: "Camera unavailable")
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(appContext))
|
||||
}
|
||||
|
||||
private fun getOrCreateOverlayEffect(): OverlayEffect {
|
||||
overlayEffect?.let { return it }
|
||||
|
||||
val handlerThread = HandlerThread("PoseOverlayEffect").apply { start() }
|
||||
overlayHandlerThread = handlerThread
|
||||
|
||||
val effect = OverlayEffect(
|
||||
CameraEffect.VIDEO_CAPTURE,
|
||||
/* queueDepth = */ 2,
|
||||
Handler(handlerThread.looper)
|
||||
) { throwable ->
|
||||
callback?.onPoseDetectorError(throwable.message ?: "Pose overlay compositing error")
|
||||
}
|
||||
effect.setOnDrawListener { frame ->
|
||||
val poseFrame = latestPoseFrame
|
||||
val canvas = frame.overlayCanvas
|
||||
// Always clear first: with no pose mode active (or no pose
|
||||
// result yet), this leaves the canvas fully transparent, so the
|
||||
// recorded frame passes through untouched.
|
||||
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
|
||||
if (poseDetectionEnabled && poseFrame != null) {
|
||||
val frameSize = frame.size
|
||||
// Mirrors the same raw-buffer-dimensions-plus-rotation-degrees
|
||||
// convention CameraX uses for ImageAnalysis/ImageProxy (see
|
||||
// PoseSkeletonRenderer.computeTransform's source-side handling).
|
||||
val targetWidth: Int
|
||||
val targetHeight: Int
|
||||
if (frame.rotationDegrees == 90 || frame.rotationDegrees == 270) {
|
||||
targetWidth = frameSize.height
|
||||
targetHeight = frameSize.width
|
||||
} else {
|
||||
targetWidth = frameSize.width
|
||||
targetHeight = frameSize.height
|
||||
}
|
||||
val transform = PoseSkeletonRenderer.computeTransform(
|
||||
sourceWidth = poseFrame.imageWidth,
|
||||
sourceHeight = poseFrame.imageHeight,
|
||||
sourceRotationDegrees = poseFrame.rotationDegrees,
|
||||
targetWidth = targetWidth,
|
||||
targetHeight = targetHeight,
|
||||
mirror = frame.isMirroring
|
||||
)
|
||||
PoseSkeletonRenderer.draw(canvas, poseFrame.pose, transform, overlayBonePaint, overlayJointPaint)
|
||||
}
|
||||
true
|
||||
}
|
||||
overlayEffect = effect
|
||||
return effect
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches/detaches the pose analyzer from the analysis stream, and
|
||||
* turns skeleton compositing into the recorded video on/off, without
|
||||
* needing a full unbind/rebind of the camera use cases. Cheap and
|
||||
* synchronous, so it's safe to call right before [startRecording] to
|
||||
* pick the mode for that recording (plain video vs. with pose baked in).
|
||||
*/
|
||||
fun setPoseDetectionEnabled(enabled: Boolean) {
|
||||
poseDetectionEnabled = enabled
|
||||
if (!enabled) {
|
||||
latestPoseFrame = null
|
||||
}
|
||||
applyPoseDetectionEnabled()
|
||||
}
|
||||
|
||||
private fun applyPoseDetectionEnabled() {
|
||||
val analysis = imageAnalysis ?: return
|
||||
val analyzer = poseAnalyzer ?: return
|
||||
if (poseDetectionEnabled) {
|
||||
analysis.setAnalyzer(cameraExecutor, analyzer)
|
||||
} else {
|
||||
analysis.clearAnalyzer()
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() {
|
||||
val cb = callback ?: return
|
||||
val capture = videoCapture
|
||||
if (capture == null) {
|
||||
cb.onRecordingError("Camera is not ready yet")
|
||||
return
|
||||
}
|
||||
if (activeRecording != null) return
|
||||
|
||||
try {
|
||||
val fileName = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(java.util.Date())
|
||||
val contentValues = ContentValues().apply {
|
||||
put(MediaStore.Video.Media.DISPLAY_NAME, "bowling_$fileName.mp4")
|
||||
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Public Movies/bowling collection so the recording shows up in the
|
||||
// Gallery/Photos app rather than app-private storage.
|
||||
put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/bowling")
|
||||
}
|
||||
}
|
||||
val outputOptions = MediaStoreOutputOptions.Builder(
|
||||
appContext.contentResolver,
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
)
|
||||
.setContentValues(contentValues)
|
||||
.build()
|
||||
|
||||
var pendingRecording = capture.output.prepareRecording(appContext, outputOptions)
|
||||
if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
pendingRecording = pendingRecording.withAudioEnabled()
|
||||
}
|
||||
|
||||
activeRecording = pendingRecording.start(ContextCompat.getMainExecutor(appContext)) { event ->
|
||||
when (event) {
|
||||
is VideoRecordEvent.Start -> cb.onRecordingStarted()
|
||||
is VideoRecordEvent.Finalize -> {
|
||||
activeRecording = null
|
||||
if (event.hasError()) {
|
||||
cb.onRecordingError(
|
||||
event.cause?.message ?: "Recording error code=${event.error}"
|
||||
)
|
||||
} else {
|
||||
cb.onRecordingFinalized(event.outputResults.outputUri)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
cb.onRecordingError(e.message ?: "Failed to start recording")
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRecording() {
|
||||
activeRecording?.stop()
|
||||
activeRecording = null
|
||||
}
|
||||
|
||||
/** Unbinds all use cases and releases the pose detector. Call from onDestroy. */
|
||||
fun release() {
|
||||
activeRecording?.stop()
|
||||
activeRecording = null
|
||||
cameraProvider?.unbindAll()
|
||||
poseAnalyzer?.close()
|
||||
poseAnalyzer = null
|
||||
overlayEffect?.close()
|
||||
overlayEffect = null
|
||||
overlayHandlerThread?.quitSafely()
|
||||
overlayHandlerThread = null
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.camera.core.ExperimentalGetImage
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.mlkit.vision.pose.Pose
|
||||
import com.google.mlkit.vision.pose.PoseDetection
|
||||
import com.google.mlkit.vision.pose.PoseDetector
|
||||
import com.google.mlkit.vision.pose.accurate.AccuratePoseDetectorOptions
|
||||
|
||||
/**
|
||||
* Bridges CameraX's [ImageAnalysis] frame stream into ML Kit's streaming
|
||||
* pose detector.
|
||||
*
|
||||
* CameraX invokes [analyze] on whatever executor was passed to
|
||||
* `ImageAnalysis.setAnalyzer(executor, this)` -- as long as that's a
|
||||
* background executor (see [CameraXController]), the actual inference work
|
||||
* never runs on the UI thread, so it can't block the UI or the video
|
||||
* recording pipeline. Note that [onResult]/[onError] themselves fire back on
|
||||
* the *main* thread: ML Kit's `Task#addOnSuccessListener`/`addOnFailureListener`
|
||||
* without an explicit `Executor` deliver on the main application thread by
|
||||
* default, regardless of which thread called `.process()`. That's
|
||||
* intentional here -- it means callers (see [BowlingCameraActivity]) can
|
||||
* update views directly from [onResult] with no extra thread hop.
|
||||
* `STREAM_MODE` on the detector itself also makes ML Kit assume frames
|
||||
* arrive close together and reuse state between them, which is what makes
|
||||
* it track a moving body smoothly instead of re-detecting from scratch.
|
||||
*/
|
||||
class PoseAnalyzer(
|
||||
private val isFrontCamera: () -> Boolean,
|
||||
private val onResult: (PoseFrameResult) -> Unit,
|
||||
private val onError: (Exception) -> Unit
|
||||
) : ImageAnalysis.Analyzer {
|
||||
|
||||
/**
|
||||
* Everything [PoseOverlayView] needs to both draw a pose and correctly
|
||||
* map it from analysis-image pixels to view pixels.
|
||||
*/
|
||||
data class PoseFrameResult(
|
||||
val pose: Pose,
|
||||
val imageWidth: Int,
|
||||
val imageHeight: Int,
|
||||
val rotationDegrees: Int,
|
||||
val isFrontCamera: Boolean
|
||||
)
|
||||
|
||||
private val detector: PoseDetector = PoseDetection.getClient(
|
||||
AccuratePoseDetectorOptions.Builder()
|
||||
.setDetectorMode(AccuratePoseDetectorOptions.STREAM_MODE)
|
||||
.build()
|
||||
)
|
||||
|
||||
// STRATEGY_KEEP_ONLY_LATEST on the ImageAnalysis use case (see
|
||||
// CameraXController) already ensures we're never handed a backlog, but
|
||||
// this guards against overlapping calls if the detector ever falls
|
||||
// behind the frame producer.
|
||||
@Volatile
|
||||
private var isProcessing = false
|
||||
|
||||
@OptIn(ExperimentalGetImage::class)
|
||||
override fun analyze(imageProxy: ImageProxy) {
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage == null || isProcessing) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
isProcessing = true
|
||||
|
||||
// Capture these before handing off to the async detector call --
|
||||
// imageProxy itself is closed as soon as the detector is done with
|
||||
// the underlying buffer, so nothing here should read from it after
|
||||
// that point.
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val width = imageProxy.width
|
||||
val height = imageProxy.height
|
||||
val frontCamera = isFrontCamera()
|
||||
|
||||
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
|
||||
|
||||
detector.process(inputImage)
|
||||
.addOnSuccessListener { pose ->
|
||||
onResult(
|
||||
PoseFrameResult(
|
||||
pose = pose,
|
||||
imageWidth = width,
|
||||
imageHeight = height,
|
||||
rotationDegrees = rotationDegrees,
|
||||
isFrontCamera = frontCamera
|
||||
)
|
||||
)
|
||||
}
|
||||
.addOnFailureListener { e -> onError(e) }
|
||||
.addOnCompleteListener {
|
||||
isProcessing = false
|
||||
// Must always close, or CameraX stalls the analysis
|
||||
// pipeline waiting for this frame's buffer to be released.
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
detector.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.jnicpp.R
|
||||
import com.google.mlkit.vision.pose.Pose
|
||||
|
||||
/**
|
||||
* Draws the 33 ML Kit pose landmarks and connecting skeleton lines on top of
|
||||
* the camera preview. The landmark topology and the coordinate mapping math
|
||||
* (analysis-image pixels -> view pixels, replicating `PreviewView`'s
|
||||
* `FILL_CENTER` scaling) live in [PoseSkeletonRenderer], shared with
|
||||
* [CameraXController]'s baked-into-the-recorded-video overlay so both paths
|
||||
* can't drift apart.
|
||||
*/
|
||||
class PoseOverlayView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
private val jointPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.skeleton_joint)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val bonePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = ContextCompat.getColor(context, R.color.skeleton_bone)
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = PoseSkeletonRenderer.STROKE_WIDTH
|
||||
}
|
||||
|
||||
private var pose: Pose? = null
|
||||
private var isFrontCamera = false
|
||||
|
||||
// Source image size and rotation, as last reported by the analyzer --
|
||||
// fed straight into PoseSkeletonRenderer.computeTransform on every
|
||||
// size/frame change.
|
||||
private var sourceWidth = 0
|
||||
private var sourceHeight = 0
|
||||
private var sourceRotationDegrees = 0
|
||||
|
||||
private var transform = Matrix()
|
||||
|
||||
/** Called from the main thread with the latest analyzer result, or null to clear. */
|
||||
fun update(frame: PoseAnalyzer.PoseFrameResult?) {
|
||||
pose = frame?.pose
|
||||
if (frame != null) {
|
||||
isFrontCamera = frame.isFrontCamera
|
||||
sourceWidth = frame.imageWidth
|
||||
sourceHeight = frame.imageHeight
|
||||
sourceRotationDegrees = frame.rotationDegrees
|
||||
}
|
||||
recomputeTransform()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
pose = null
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
super.onSizeChanged(w, h, oldw, oldh)
|
||||
recomputeTransform()
|
||||
}
|
||||
|
||||
private fun recomputeTransform() {
|
||||
transform = PoseSkeletonRenderer.computeTransform(
|
||||
sourceWidth = sourceWidth,
|
||||
sourceHeight = sourceHeight,
|
||||
sourceRotationDegrees = sourceRotationDegrees,
|
||||
targetWidth = width,
|
||||
targetHeight = height,
|
||||
// Front camera preview is mirrored; flip the x axis about the
|
||||
// view's center so the overlay matches what's on screen.
|
||||
mirror = isFrontCamera
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val currentPose = pose ?: return
|
||||
PoseSkeletonRenderer.draw(canvas, currentPose, transform, bonePaint, jointPaint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.example.jnicpp.bowling
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PointF
|
||||
import com.google.mlkit.vision.pose.Pose
|
||||
import com.google.mlkit.vision.pose.PoseLandmark
|
||||
|
||||
/**
|
||||
* Shared skeleton geometry/drawing code used by both [PoseOverlayView] (live
|
||||
* on-screen overlay) and [CameraXController]'s [androidx.camera.effects.OverlayEffect]
|
||||
* draw listener (baking the skeleton into the recorded video). Keeping this
|
||||
* in one place means both call sites use the exact same landmark topology
|
||||
* and the exact same center-crop mapping math, instead of two
|
||||
* independently-maintained copies that could silently drift apart.
|
||||
*/
|
||||
object PoseSkeletonRenderer {
|
||||
|
||||
const val MIN_LIKELIHOOD = 0.5f
|
||||
const val DOT_RADIUS = 8f
|
||||
const val STROKE_WIDTH = 8f
|
||||
|
||||
// Standard BlazePose 33-point topology, same skeleton ML Kit's own
|
||||
// sample app draws.
|
||||
val BONES: List<Pair<Int, Int>> = listOf(
|
||||
// face
|
||||
PoseLandmark.NOSE to PoseLandmark.LEFT_EYE_INNER,
|
||||
PoseLandmark.LEFT_EYE_INNER to PoseLandmark.LEFT_EYE,
|
||||
PoseLandmark.LEFT_EYE to PoseLandmark.LEFT_EYE_OUTER,
|
||||
PoseLandmark.LEFT_EYE_OUTER to PoseLandmark.LEFT_EAR,
|
||||
PoseLandmark.NOSE to PoseLandmark.RIGHT_EYE_INNER,
|
||||
PoseLandmark.RIGHT_EYE_INNER to PoseLandmark.RIGHT_EYE,
|
||||
PoseLandmark.RIGHT_EYE to PoseLandmark.RIGHT_EYE_OUTER,
|
||||
PoseLandmark.RIGHT_EYE_OUTER to PoseLandmark.RIGHT_EAR,
|
||||
PoseLandmark.LEFT_MOUTH to PoseLandmark.RIGHT_MOUTH,
|
||||
// torso
|
||||
PoseLandmark.LEFT_SHOULDER to PoseLandmark.RIGHT_SHOULDER,
|
||||
PoseLandmark.LEFT_HIP to PoseLandmark.RIGHT_HIP,
|
||||
PoseLandmark.LEFT_SHOULDER to PoseLandmark.LEFT_HIP,
|
||||
PoseLandmark.RIGHT_SHOULDER to PoseLandmark.RIGHT_HIP,
|
||||
// left limb
|
||||
PoseLandmark.LEFT_SHOULDER to PoseLandmark.LEFT_ELBOW,
|
||||
PoseLandmark.LEFT_ELBOW to PoseLandmark.LEFT_WRIST,
|
||||
PoseLandmark.LEFT_WRIST to PoseLandmark.LEFT_THUMB,
|
||||
PoseLandmark.LEFT_WRIST to PoseLandmark.LEFT_PINKY,
|
||||
PoseLandmark.LEFT_WRIST to PoseLandmark.LEFT_INDEX,
|
||||
PoseLandmark.LEFT_INDEX to PoseLandmark.LEFT_PINKY,
|
||||
PoseLandmark.LEFT_HIP to PoseLandmark.LEFT_KNEE,
|
||||
PoseLandmark.LEFT_KNEE to PoseLandmark.LEFT_ANKLE,
|
||||
PoseLandmark.LEFT_ANKLE to PoseLandmark.LEFT_HEEL,
|
||||
PoseLandmark.LEFT_HEEL to PoseLandmark.LEFT_FOOT_INDEX,
|
||||
// right limb
|
||||
PoseLandmark.RIGHT_SHOULDER to PoseLandmark.RIGHT_ELBOW,
|
||||
PoseLandmark.RIGHT_ELBOW to PoseLandmark.RIGHT_WRIST,
|
||||
PoseLandmark.RIGHT_WRIST to PoseLandmark.RIGHT_THUMB,
|
||||
PoseLandmark.RIGHT_WRIST to PoseLandmark.RIGHT_PINKY,
|
||||
PoseLandmark.RIGHT_WRIST to PoseLandmark.RIGHT_INDEX,
|
||||
PoseLandmark.RIGHT_INDEX to PoseLandmark.RIGHT_PINKY,
|
||||
PoseLandmark.RIGHT_HIP to PoseLandmark.RIGHT_KNEE,
|
||||
PoseLandmark.RIGHT_KNEE to PoseLandmark.RIGHT_ANKLE,
|
||||
PoseLandmark.RIGHT_ANKLE to PoseLandmark.RIGHT_HEEL,
|
||||
PoseLandmark.RIGHT_HEEL to PoseLandmark.RIGHT_FOOT_INDEX
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds the matrix that maps ML Kit's landmark coordinates (pixels in
|
||||
* the *upright* analysis-image space) onto a [targetWidth] x
|
||||
* [targetHeight] canvas, replicating [PreviewView]'s `FILL_CENTER`
|
||||
* (center-crop) scaling -- otherwise the skeleton drifts off the body
|
||||
* wherever the source and target aspect ratios differ. The math mirrors
|
||||
* Google's own ML Kit vision-quickstart `GraphicOverlay` sample.
|
||||
*
|
||||
* @param sourceWidth/[sourceHeight] the analysis image's raw buffer
|
||||
* dimensions (as reported by CameraX/ML Kit), i.e. *before* accounting
|
||||
* for [sourceRotationDegrees].
|
||||
* @param sourceRotationDegrees rotation needed to bring the raw buffer
|
||||
* to upright orientation; for 90/270 this swaps width and height
|
||||
* before computing the scale/crop.
|
||||
* @param targetWidth/[targetHeight] the already-upright destination
|
||||
* surface (a View's pixel size, or a video frame buffer's size after
|
||||
* the caller has applied its own rotation swap).
|
||||
* @param mirror true if the destination is horizontally mirrored
|
||||
* relative to the source (e.g. a mirrored front-camera preview).
|
||||
*/
|
||||
fun computeTransform(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
sourceRotationDegrees: Int,
|
||||
targetWidth: Int,
|
||||
targetHeight: Int,
|
||||
mirror: Boolean
|
||||
): Matrix {
|
||||
val transform = Matrix()
|
||||
val imageWidth: Int
|
||||
val imageHeight: Int
|
||||
if (sourceRotationDegrees == 90 || sourceRotationDegrees == 270) {
|
||||
imageWidth = sourceHeight
|
||||
imageHeight = sourceWidth
|
||||
} else {
|
||||
imageWidth = sourceWidth
|
||||
imageHeight = sourceHeight
|
||||
}
|
||||
if (imageWidth <= 0 || imageHeight <= 0 || targetWidth <= 0 || targetHeight <= 0) {
|
||||
return transform
|
||||
}
|
||||
|
||||
val viewAspect = targetWidth.toFloat() / targetHeight
|
||||
val imageAspect = imageWidth.toFloat() / imageHeight
|
||||
|
||||
val scale: Float
|
||||
var dx = 0f
|
||||
var dy = 0f
|
||||
if (viewAspect > imageAspect) {
|
||||
// Target is relatively wider than the image -> top/bottom cropped.
|
||||
scale = targetWidth.toFloat() / imageWidth
|
||||
dy = (targetHeight - imageHeight * scale) / 2f
|
||||
} else {
|
||||
// Target is relatively taller than the image -> left/right cropped.
|
||||
scale = targetHeight.toFloat() / imageHeight
|
||||
dx = (targetWidth - imageWidth * scale) / 2f
|
||||
}
|
||||
|
||||
transform.setScale(scale, scale)
|
||||
transform.postTranslate(dx, dy)
|
||||
if (mirror) {
|
||||
transform.postScale(-1f, 1f, targetWidth / 2f, targetHeight / 2f)
|
||||
}
|
||||
return transform
|
||||
}
|
||||
|
||||
/** Draws [pose]'s bones and joints onto [canvas], mapping each landmark through [transform]. */
|
||||
fun draw(canvas: Canvas, pose: Pose, transform: Matrix, bonePaint: Paint, jointPaint: Paint) {
|
||||
val mappedPoint = FloatArray(2)
|
||||
fun mapPoint(x: Float, y: Float): PointF {
|
||||
mappedPoint[0] = x
|
||||
mappedPoint[1] = y
|
||||
transform.mapPoints(mappedPoint)
|
||||
return PointF(mappedPoint[0], mappedPoint[1])
|
||||
}
|
||||
|
||||
for ((startType, endType) in BONES) {
|
||||
val start = pose.getPoseLandmark(startType) ?: continue
|
||||
val end = pose.getPoseLandmark(endType) ?: continue
|
||||
if (start.inFrameLikelihood < MIN_LIKELIHOOD || end.inFrameLikelihood < MIN_LIKELIHOOD) continue
|
||||
val p1 = mapPoint(start.position.x, start.position.y)
|
||||
val p2 = mapPoint(end.position.x, end.position.y)
|
||||
canvas.drawLine(p1.x, p1.y, p2.x, p2.y, bonePaint)
|
||||
}
|
||||
|
||||
for (landmark in pose.allPoseLandmarks) {
|
||||
if (landmark.inFrameLikelihood < MIN_LIKELIHOOD) continue
|
||||
val p = mapPoint(landmark.position.x, landmark.position.y)
|
||||
canvas.drawCircle(p.x, p.y, DOT_RADIUS, jointPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/recording_red" />
|
||||
</shape>
|
||||
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Landscape button arrangement: with the screen wide and short, a bottom bar
|
||||
(see the portrait default in res/layout/) would leave little room for the
|
||||
preview and put the record button awkwardly close to the edge, so it moves
|
||||
to a vertically-centered side column instead. Same view IDs as the
|
||||
portrait layout so BowlingCameraActivity's view-binding code needs no
|
||||
orientation-specific logic - Android just swaps which XML gets inflated.
|
||||
-->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:keepScreenOn="true"
|
||||
tools:context=".bowling.BowlingCameraActivity">
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/camera_preview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<com.example.jnicpp.bowling.PoseOverlayView
|
||||
android:id="@+id/pose_overlay"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="@id/camera_preview"
|
||||
app:layout_constraintBottom_toBottomOf="@id/camera_preview"
|
||||
app:layout_constraintStart_toStartOf="@id/camera_preview"
|
||||
app:layout_constraintEnd_toEndOf="@id/camera_preview" />
|
||||
|
||||
<!--
|
||||
Recording indicator: red dot + elapsed timer, only visible while
|
||||
recording. Anchored below btn_back (rather than parent's top) so the
|
||||
two never overlap - btn_back is declared later in this file so it
|
||||
draws on top, but ConstraintLayout resolves constraint references
|
||||
regardless of declaration order, so this forward reference is fine.
|
||||
-->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_recording_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@color/overlay_scrim"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
app:layout_constraintTop_toBottomOf="@id/btn_back"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginStart="16dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/view_recording_dot"
|
||||
android:layout_width="12dp"
|
||||
android:layout_height="12dp"
|
||||
android:background="@drawable/shape_recording_dot" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_timer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/recording_timer_placeholder"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<!--
|
||||
Plain recording, mirrored onto the start edge at the same vertical
|
||||
center as btn_record_with_pose on the end edge: no live pose overlay,
|
||||
ImageAnalysis stays idle (no analyzer attached).
|
||||
-->
|
||||
<Button
|
||||
android:id="@+id/btn_record_video"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="32dp"
|
||||
android:text="@string/record_video"
|
||||
app:layout_constraintTop_toTopOf="@id/btn_record_with_pose"
|
||||
app:layout_constraintBottom_toBottomOf="@id/btn_record_with_pose"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<!-- Side column instead of a bottom bar: vertically centered, hugging the end edge. Recording with the live pose skeleton overlay shown on screen (not baked into the saved video). -->
|
||||
<Button
|
||||
android:id="@+id/btn_record_with_pose"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="32dp"
|
||||
android:text="@string/record_with_pose"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<!-- Above the record-with-pose button in the same side column, rather than the top corner. -->
|
||||
<Button
|
||||
android:id="@+id/btn_switch_camera"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="@string/switch_camera"
|
||||
app:layout_constraintBottom_toTopOf="@id/btn_record_with_pose"
|
||||
app:layout_constraintEnd_toEndOf="@id/btn_record_with_pose" />
|
||||
|
||||
<!-- Shown instead of the camera UI when CAMERA/RECORD_AUDIO aren't granted -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_permission_rationale"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="24dp"
|
||||
android:background="@color/black"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_permission_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permission_rationale_message"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_grant_permissions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/grant_permissions" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Declared last so it draws above the permission rationale screen too,
|
||||
keeping a way back out of this Activity available in every state. -->
|
||||
<Button
|
||||
android:id="@+id/btn_back"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:text="@string/back"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,157 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Portrait (default) button arrangement: record button in a bottom bar,
|
||||
back/switch-camera in the top corners. See res/layout-land/ for the
|
||||
landscape variant, which moves the record button to a side column instead
|
||||
- Android picks whichever of the two matches the current orientation
|
||||
automatically, no code needed.
|
||||
-->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:keepScreenOn="true"
|
||||
tools:context=".bowling.BowlingCameraActivity">
|
||||
|
||||
<androidx.camera.view.PreviewView
|
||||
android:id="@+id/camera_preview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<com.example.jnicpp.bowling.PoseOverlayView
|
||||
android:id="@+id/pose_overlay"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toTopOf="@id/camera_preview"
|
||||
app:layout_constraintBottom_toBottomOf="@id/camera_preview"
|
||||
app:layout_constraintStart_toStartOf="@id/camera_preview"
|
||||
app:layout_constraintEnd_toEndOf="@id/camera_preview" />
|
||||
|
||||
<!--
|
||||
Recording indicator: red dot + elapsed timer, only visible while
|
||||
recording. Anchored below btn_back (rather than parent's top) so the
|
||||
two never overlap - btn_back is declared later in this file so it
|
||||
draws on top, but ConstraintLayout resolves constraint references
|
||||
regardless of declaration order, so this forward reference is fine.
|
||||
-->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_recording_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@color/overlay_scrim"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
app:layout_constraintTop_toBottomOf="@id/btn_back"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginStart="16dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/view_recording_dot"
|
||||
android:layout_width="12dp"
|
||||
android:layout_height="12dp"
|
||||
android:background="@drawable/shape_recording_dot" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_timer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/recording_timer_placeholder"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Plain recording: no live pose overlay, ImageAnalysis stays idle (no analyzer attached). -->
|
||||
<Button
|
||||
android:id="@+id/btn_record_video"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/record_video"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/btn_record_with_pose"
|
||||
app:layout_constraintHorizontal_chainStyle="packed" />
|
||||
|
||||
<!-- Recording with the live pose skeleton overlay shown on screen (not baked into the saved video). -->
|
||||
<Button
|
||||
android:id="@+id/btn_record_with_pose"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/record_with_pose"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/btn_record_video"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<!-- Overlaid on the preview itself so it's reachable while the camera UI is showing. -->
|
||||
<Button
|
||||
android:id="@+id/btn_switch_camera"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/switch_camera"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<!-- Shown instead of the camera UI when CAMERA/RECORD_AUDIO aren't granted -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_permission_rationale"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="24dp"
|
||||
android:background="@color/black"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_permission_message"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permission_rationale_message"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_grant_permissions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/grant_permissions" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Declared last so it draws above the permission rationale screen too,
|
||||
keeping a way back out of this Activity available in every state. -->
|
||||
<Button
|
||||
android:id="@+id/btn_back"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:text="@string/back"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
<android.opengl.GLSurfaceView
|
||||
android:id="@+id/gl_surface_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,16 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.Jnicpp" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
<!-- Bowling camera screen -->
|
||||
<color name="recording_red">#FFE53935</color>
|
||||
<color name="skeleton_joint">#FF00E5FF</color>
|
||||
<color name="skeleton_bone">#FF76FF03</color>
|
||||
<color name="overlay_scrim">#99000000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,19 @@
|
||||
<resources>
|
||||
<string name="app_name">jnicpp</string>
|
||||
|
||||
<!-- Bowling camera screen -->
|
||||
<string name="permission_rationale_message">Camera and microphone access are required to record your bowling delivery for form analysis.</string>
|
||||
<string name="permission_denied_message">Camera and microphone permissions were denied. Grant them in Settings to use this feature.</string>
|
||||
<string name="grant_permissions">Grant permissions</string>
|
||||
<string name="open_settings">Open settings</string>
|
||||
<string name="record_video">Record video</string>
|
||||
<string name="record_with_pose">Record with pose</string>
|
||||
<string name="stop_recording">Stop recording</string>
|
||||
<string name="switch_camera">Switch camera</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="switch_camera_while_recording">Stop recording before switching cameras</string>
|
||||
<string name="recording_timer_placeholder">00:00</string>
|
||||
<string name="error_camera_unavailable">Camera unavailable: %1$s</string>
|
||||
<string name="error_recording_failed">Recording failed: %1$s</string>
|
||||
<string name="error_pose_detector">Pose detector error: %1$s</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,26 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.Jnicpp" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
|
||||
<!--
|
||||
Camera preview screen: no action bar, since it would otherwise crop the
|
||||
video preview along the top edge. The preview should fill the whole
|
||||
window except for the record/back/switch-camera buttons drawn over it.
|
||||
-->
|
||||
<style name="Theme.Jnicpp.Camera" parent="Theme.Jnicpp">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.example.jnicpp
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
# org.gradle.parallel=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
@@ -0,0 +1,12 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
|
||||
toolchainVersion=21
|
||||
@@ -0,0 +1,38 @@
|
||||
[versions]
|
||||
agp = "9.1.1"
|
||||
coreKtx = "1.18.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
espressoCore = "3.7.0"
|
||||
appcompat = "1.6.1"
|
||||
material = "1.10.0"
|
||||
constraintlayout = "2.1.4"
|
||||
camerax = "1.6.1"
|
||||
lifecycle = "2.11.0"
|
||||
activityKtx = "1.9.3"
|
||||
mlkitPoseDetection = "18.0.0-beta5"
|
||||
kotlinxCoroutines = "1.9.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||
androidx-camera-video = { group = "androidx.camera", name = "camera-video", version.ref = "camerax" }
|
||||
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||
androidx-camera-effects = { group = "androidx.camera", name = "camera-effects", version.ref = "camerax" }
|
||||
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
|
||||
mlkit-pose-detection-accurate = { group = "com.google.mlkit", name = "pose-detection-accurate", version.ref = "mlkitPoseDetection" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
#Fri Apr 10 12:25:49 SGT 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original 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.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# 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 ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# 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
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
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
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# 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"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
@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
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@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=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@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% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 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!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "jnicpp"
|
||||
include ':app'
|
||||