Initial commit
@@ -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)
|
||||
}
|
||||
}
|
||||