From 9d1db7288e89538b8e246378c7c3b1a4b44b6a41 Mon Sep 17 00:00:00 2001 From: chaneylc Date: Mon, 8 Jan 2024 14:11:28 -0600 Subject: [PATCH 1/6] init commit for Canon Camera Control API integration --- app/build.gradle | 6 + .../tracker/activities/DataGridActivity.kt | 4 + .../tracker/canon/BitmapConverterFactory.kt | 21 + .../tracker/canon/CameraControlApi.kt | 65 +++ .../tracker/canon/CameraControlFactory.kt | 66 +++ .../com/fieldbook/tracker/canon/Controller.kt | 333 ++++++++++++++ .../tracker/canon/models/ContentPath.kt | 5 + .../tracker/canon/models/CurrentPath.kt | 6 + .../tracker/canon/models/DeviceInformation.kt | 10 + .../tracker/canon/models/LiveViewAction.kt | 6 + .../tracker/canon/models/LiveViewSettings.kt | 6 + .../tracker/canon/models/MovieMode.kt | 5 + .../tracker/canon/models/ShutterAction.kt | 5 + .../tracker/dialogs/CanonConnectDialog.kt | 108 +++++ .../tracker/offbeat/traits/formats/Formats.kt | 4 +- .../traits/formats/contracts/CanonFormat.kt | 18 + .../tracker/preferences/GeneralKeys.java | 4 + .../tracker/traits/BaseTraitLayout.java | 3 +- .../fieldbook/tracker/traits/CanonTrait.kt | 422 ++++++++++++++++++ .../tracker/traits/LayoutCollections.java | 1 + .../tracker/traits/UsbCameraTraitLayout.kt | 2 +- .../res/layout/dialog_canon_connection.xml | 24 + app/src/main/res/layout/trait_canon.xml | 68 +++ app/src/main/res/values/strings.xml | 7 +- .../main/res/xml/preferences_experimental.xml | 20 +- 25 files changed, 1212 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/Controller.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/dialogs/CanonConnectDialog.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt create mode 100644 app/src/main/res/layout/dialog_canon_connection.xml create mode 100644 app/src/main/res/layout/trait_canon.xml diff --git a/app/build.gradle b/app/build.gradle index cc4c1da3c..f3a08709c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -203,6 +203,12 @@ dependencies { kapt 'com.android.databinding:compiler:3.1.4' + // Retrofit + implementation "com.squareup.retrofit2:retrofit:2.9.0" + // Retrofit with Scalar Converter + implementation "com.squareup.retrofit2:converter-scalars:2.9.0" + implementation "com.squareup.retrofit2:converter-gson:2.9.0" + testImplementation 'androidx.test:core:1.5.0' testImplementation 'org.robolectric:robolectric:4.3.1' testImplementation 'junit:junit:4.13.2' diff --git a/app/src/main/java/com/fieldbook/tracker/activities/DataGridActivity.kt b/app/src/main/java/com/fieldbook/tracker/activities/DataGridActivity.kt index 027f17e22..5805ea490 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/DataGridActivity.kt +++ b/app/src/main/java/com/fieldbook/tracker/activities/DataGridActivity.kt @@ -48,6 +48,10 @@ import javax.inject.Inject @AndroidEntryPoint class DataGridActivity : ThemedActivity(), CoroutineScope by MainScope(), ITableViewListener { + companion object { + val TAG = DataGridActivity::class.simpleName + } + /*** * Polymorphism class structure to serve different cell types to the grid. */ diff --git a/app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt b/app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt new file mode 100644 index 000000000..def6ea31b --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt @@ -0,0 +1,21 @@ +package com.fieldbook.tracker.canon + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.Type + +class BitmapConverterFactory : Converter.Factory() { + + override fun responseBodyConverter(type: Type, annotations: Array, retrofit: Retrofit): Converter? { + return if (type == Bitmap::class.java) { + Converter { + value -> BitmapFactory.decodeStream(value.byteStream()) + } + } else { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt b/app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt new file mode 100644 index 000000000..4ea185bdf --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt @@ -0,0 +1,65 @@ +package com.fieldbook.tracker.canon + +import android.graphics.Bitmap +import com.fieldbook.tracker.canon.models.ContentPath +import com.fieldbook.tracker.canon.models.CurrentPath +import com.fieldbook.tracker.canon.models.DeviceInformation +import com.fieldbook.tracker.canon.models.LiveViewAction +import com.fieldbook.tracker.canon.models.LiveViewSettings +import com.fieldbook.tracker.canon.models.MovieMode +import com.fieldbook.tracker.canon.models.ShutterAction +import com.google.gson.JsonObject +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path + +interface CameraControlApi { + + //Version 1.0.0 Endpoints + @GET("ver100/deviceinformation") + suspend fun getDeviceInformation(): DeviceInformation + + @POST("ver100/shooting/control/moviemode") + suspend fun postMovieMode(@Body movieMode: MovieMode): JsonObject + + @GET("ver100/shooting/control/moviemode") + suspend fun getMovieMode(): MovieMode + + @POST("ver100/shooting/control/shutterbutton") + suspend fun postShutterButton(@Body action: ShutterAction): JsonObject + + @POST("ver100/shooting/liveview") + suspend fun postLiveViewSettings(@Body settings: LiveViewSettings): JsonObject + + @POST("ver100/shooting/liveview/rtp") + suspend fun postLiveViewAction(@Body action: LiveViewAction): JsonObject + + @GET("ver100/shooting/liveview/flip") + suspend fun getLiveStream(): Bitmap + + //Version 1.1.0 Get Current Directory + + @GET("ver110/devicestatus/currentdirectory") + suspend fun getCurrentDirectory(): CurrentPath + + @GET("ver110/devicestatus/currentstorage") + suspend fun getCurrentStorage(): CurrentPath + + // Same through Version 1.0.0 -> 1.3.0 but devices implement different versions + @GET("{ver}/contents/{drive}/{dir}") + suspend fun getContents( + @Path("ver") version: String, + @Path("drive") drive: String, + @Path("dir") dir: String + ): ContentPath + + @GET("{ver}/contents/{drive}/{dir}/{name}") + suspend fun getContents( + @Path("ver") version: String, + @Path("drive") drive: String, + @Path("dir") dir: String, + @Path("name") name: String? = "" + ): Bitmap + +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt b/app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt new file mode 100644 index 000000000..0e8bbf259 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt @@ -0,0 +1,66 @@ +package com.fieldbook.tracker.canon + +import android.content.SharedPreferences +import com.fieldbook.tracker.preferences.GeneralKeys +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory +import java.util.concurrent.TimeUnit + + +class CameraControlFactory( + private val preferences: SharedPreferences +) { + + fun create(): CameraControlApi? = try { + + val ip = preferences.getString(GeneralKeys.CANON_IP, null)!! + val port = preferences.getString(GeneralKeys.CANON_PORT, null)!! + + val baseUrl = "http://$ip:$port/ccapi/" + + val client = okhttp3.OkHttpClient.Builder() + .callTimeout(5, TimeUnit.MINUTES) + .connectTimeout(5, TimeUnit.MINUTES) + .addInterceptor { chain -> + + try { + + var request = chain.request() + + val ip = preferences.getString(GeneralKeys.CANON_IP, "").toString() + val port = preferences.getString(GeneralKeys.CANON_PORT, "").toString().toInt() + + val a = request.url.newBuilder() + .host(ip) + .port(port) + .build() + + request = request.newBuilder() + .url(a) + .build() + + chain.proceed(request) + + } catch (e: Exception) { + + chain.proceed(chain.request()) + } + } + .build() + + val retrofit = Retrofit.Builder() + .client(client) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(BitmapConverterFactory()) + .addConverterFactory(GsonConverterFactory.create()) + .baseUrl(baseUrl) + .build() + + retrofit.create(CameraControlApi::class.java) + + } catch (e: Exception) { + + null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/Controller.kt b/app/src/main/java/com/fieldbook/tracker/canon/Controller.kt new file mode 100644 index 000000000..5a97e02cb --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/Controller.kt @@ -0,0 +1,333 @@ +package com.fieldbook.tracker.canon + +import android.graphics.Bitmap +import android.util.Log +import com.fieldbook.tracker.canon.models.DeviceInformation +import com.fieldbook.tracker.canon.models.LiveViewSettings +import com.fieldbook.tracker.canon.models.MovieMode +import com.fieldbook.tracker.canon.models.ShutterAction +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.ConnectException + +class Controller(private val api: CameraControlApi) { + + companion object { + const val TAG = "CanonController" + private const val NUMBER_OF_CONNECTION_ATTEMPTS = 32 + } + + interface ControllerBridge { + fun onConnected() + fun onStartCaptureUi() + fun onReceiveStreamImage(bmp: Bitmap) + fun onFail() + fun saveBitmap(bmp: Bitmap) + } + + private var scope = CoroutineScope(Dispatchers.IO) + + fun establishStream(bridge: ControllerBridge) { + + scope.launch(Dispatchers.IO) { + + var response: DeviceInformation? = null + + for (i in 0..NUMBER_OF_CONNECTION_ATTEMPTS) { + + response = getDeviceInformation() + + if (response != null) break + + delay(1000L) + } + + withContext(Dispatchers.Main) { + + if (response == null) bridge.onFail() + else bridge.onConnected() + + } + + if (response != null) { + + switchCameraToStillCapture(bridge) + } + } + } + + private suspend fun switchCameraToStillCapture(bridge: ControllerBridge) { + + if (postMovieModeOff() == null) { + + withContext(Dispatchers.Main) { + + bridge.onFail() + } + + } else switchCameraPreviewOn(bridge) + + delay(1000L) + } + + private suspend fun switchCameraPreviewOn(bridge: ControllerBridge) { + + if (postLiveViewSettings() == null) { + + withContext(Dispatchers.Main) { + + bridge.onFail() + } + } else { + + withContext(Dispatchers.Main) { + bridge.onStartCaptureUi() + } + + startLiveStreamFeed(bridge) + } + + delay(1000L) + } + + private suspend fun startLiveStreamFeed(bridge: ControllerBridge) { + + while (true) { + + try { + + val bmp = api.getLiveStream() + + withContext(Dispatchers.Main) { + + bridge.onReceiveStreamImage(bmp) + + } + + } catch (e: Exception) { + + withContext(Dispatchers.Main) { + + bridge.onFail() + } + + break + + } + + delay(1000L) + } + } + + fun awaitConnection(onStartConnection: () -> Unit) { + + scope.cancel() + + scope = CoroutineScope(Dispatchers.IO) + + scope.launch { + + withContext(Dispatchers.IO) { + + while (true) { + + val response = getDeviceInformation() + + if (response != null) { + + withContext(Dispatchers.Main) { + + onStartConnection() + + } + + break + } + + delay(1000L) + } + } + } + } + + fun postCameraShutter(bridge: ControllerBridge) { + + scope.launch { + + withContext(Dispatchers.IO) { + + val postShutter = postShutterButton() + + if (postShutter != null) { + + val bmp = getLatestImageFromCanon() + + if (bmp != null) { + + bridge.saveBitmap(bmp) + } + } + } + } + } + + private suspend fun postShutterButton() = try { + + api.postShutterButton( + ShutterAction( + af = true + ) + ) + + } catch (e: ConnectException) { + + null + + } + + private suspend fun getDeviceInformation() = try { + + api.getDeviceInformation() + + } catch (e: ConnectException) { + + null + + } + + private suspend fun postMovieModeOff() = try { + + api.postMovieMode(MovieMode("off")) + + } catch (e: Exception) { + + null + + } + + private suspend fun postLiveViewSettings() = try { + + api.postLiveViewSettings( + LiveViewSettings( + "medium", + "on" + ) + ) + + } catch (e: Exception) { + + null + + } + + private suspend fun getLatestImageFromCanon(): Bitmap? { + + val storage = getCurrentStorage() + val dir = getCurrentDir() + + if (storage != null && dir != null) { + + val contents = getContents(storage.name, dir.name) + + val imagePath = contents?.path?.maxBy { + + try { + + //parse out the index in filenames: IMG_108.JPG -> 108 + val id = it.split("_")[1].split(".")[0].toInt() + + Log.d(TAG, id.toString()) + + id + + } catch (e: Exception) { + + Int.MIN_VALUE + + } + } + + val name = imagePath?.split("/")?.last() + + if (name != null) { + + return getImage(storage.name, dir.name, name) + + } + } + + return null + } + + private suspend fun getContents(drive: String, dir: String) = try { + + api.getContents("ver120", drive, dir) + + } catch (e: Exception) { + + getContentsV110(drive, dir) + } + + private suspend fun getContentsV110(drive: String, dir: String) = try { + + api.getContents("ver110", drive, dir) + + } catch (e: Exception) { + + getContentsV100(drive, dir) + } + + private suspend fun getContentsV100(drive: String, dir: String) = try { + + api.getContents("ver100", drive, dir) + + } catch (e: Exception) { + + null + + } + + private suspend fun getImage(drive: String, dir: String, name: String? = null) = try { + + api.getContents("ver120", drive, dir, name) + + } catch (e: Exception) { + + getImageV110(drive, dir) + } + + private suspend fun getImageV110(drive: String, dir: String, name: String? = null) = try { + + api.getContents("ver110", drive, dir, name) + + } catch (e: Exception) { + + getImageV100(drive, dir) + } + + private suspend fun getImageV100(drive: String, dir: String, name: String? = null) = try { + + api.getContents("ver100", drive, dir, name) + + } catch (e: Exception) { + + null + + } + + private suspend fun getCurrentStorage() = try { + api.getCurrentStorage() + } catch (e: Exception) { + null + } + + private suspend fun getCurrentDir() = try { + api.getCurrentDirectory() + } catch (e: Exception) { + null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt new file mode 100644 index 000000000..051586dd3 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt @@ -0,0 +1,5 @@ +package com.fieldbook.tracker.canon.models + +data class ContentPath( + val path: Array + ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt new file mode 100644 index 000000000..2b7b065b6 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt @@ -0,0 +1,6 @@ +package com.fieldbook.tracker.canon.models + +data class CurrentPath( + val name: String, + val path: String + ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt new file mode 100644 index 000000000..480cdd04d --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt @@ -0,0 +1,10 @@ +package com.fieldbook.tracker.canon.models + +data class DeviceInformation( + val manufacturer: String, + val productname: String, + val guid: String, + val serialnumber: String, + val macaddress: String, + val firmwareversion: String +) diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt new file mode 100644 index 000000000..df0d2faaa --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt @@ -0,0 +1,6 @@ +package com.fieldbook.tracker.canon.models + +data class LiveViewAction( + val action: String, + val ipaddress: String + ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt new file mode 100644 index 000000000..412043d72 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt @@ -0,0 +1,6 @@ +package com.fieldbook.tracker.canon.models + +data class LiveViewSettings( + var liveviewsize: String, + var cameradisplay: String + ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt new file mode 100644 index 000000000..6911e7368 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt @@ -0,0 +1,5 @@ +package com.fieldbook.tracker.canon.models + +data class MovieMode( + var action: String + ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt new file mode 100644 index 000000000..cf3dc81f5 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt @@ -0,0 +1,5 @@ +package com.fieldbook.tracker.canon.models + +data class ShutterAction( + val af: Boolean + ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/CanonConnectDialog.kt b/app/src/main/java/com/fieldbook/tracker/dialogs/CanonConnectDialog.kt new file mode 100644 index 000000000..5298369e3 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/CanonConnectDialog.kt @@ -0,0 +1,108 @@ +package com.fieldbook.tracker.dialogs + +import android.app.Activity +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.RecyclerView +import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.CollectActivity +import com.fieldbook.tracker.activities.TraitEditorActivity +import com.fieldbook.tracker.adapters.TraitFormatAdapter +import com.fieldbook.tracker.database.DataHelper +import com.fieldbook.tracker.objects.TraitObject +import com.fieldbook.tracker.offbeat.traits.formats.Formats +import com.fieldbook.tracker.offbeat.traits.formats.TraitFormatParametersAdapter +import com.fieldbook.tracker.offbeat.traits.formats.ValidationResult +import com.fieldbook.tracker.offbeat.traits.formats.ui.ParameterScrollView +import com.fieldbook.tracker.preferences.GeneralKeys +import com.fieldbook.tracker.utilities.SoundHelperImpl +import com.fieldbook.tracker.utilities.VibrateUtil +import dagger.hilt.android.AndroidEntryPoint +import org.phenoapps.utils.SoftKeyboardUtil +import javax.inject.Inject + +@AndroidEntryPoint +class CanonConnectDialog( + private val activity: Activity, + private val onConfigured: () -> Unit +) : + DialogFragment() { + + // UI elements of new trait dialog + private lateinit var ipEditText: EditText + private lateinit var portEditText: EditText + + override fun onStart() { + super.onStart() + /** + * EditText's inside a dialog fragment need certain window flags to be cleared + * for the software keyboard to show. + */ + dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + + //following stretches dialog a bit for more pixel real estate + val params = dialog?.window?.attributes + params?.width = LinearLayout.LayoutParams.MATCH_PARENT + params?.height = LinearLayout.LayoutParams.WRAP_CONTENT + dialog?.window?.attributes = params + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + + val view = layoutInflater.inflate(R.layout.dialog_canon_connection, null) + + ipEditText = view.findViewById(R.id.dialog_canon_ip_et) + portEditText = view.findViewById(R.id.dialog_canon_port_et) + + context?.let { ctx -> + val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) + ipEditText.setText(prefs.getString(GeneralKeys.CANON_IP, "").toString()) + portEditText.setText(prefs.getString(GeneralKeys.CANON_PORT, "").toString()) + } + + val builder = AlertDialog.Builder( + activity, + R.style.AppAlertDialog + ) + + builder.setTitle("Canon Connect") + .setCancelable(true) + .setView(view) + + builder.setPositiveButton(R.string.next) { _, _ -> + + val ip = ipEditText.text.toString() + val port = portEditText.text.toString() + + context?.let { ctx -> + + PreferenceManager.getDefaultSharedPreferences(ctx).edit() + .putString(GeneralKeys.CANON_IP, ip) + .putString(GeneralKeys.CANON_PORT, port) + .apply() + } + + onConfigured() + } + + builder.setNegativeButton(R.string.dialog_cancel) { _, _ -> } + builder.setNeutralButton(R.string.dialog_back) { _, _ -> } + + return builder.create() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/Formats.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/Formats.kt index 4e705cf5f..3f809191f 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/Formats.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/Formats.kt @@ -4,6 +4,7 @@ import android.content.Context import com.fieldbook.tracker.offbeat.traits.formats.contracts.AudioFormat import com.fieldbook.tracker.offbeat.traits.formats.contracts.BooleanFormat import com.fieldbook.tracker.offbeat.traits.formats.contracts.BrapiFormat +import com.fieldbook.tracker.offbeat.traits.formats.contracts.CanonFormat import com.fieldbook.tracker.offbeat.traits.formats.contracts.CategoricalFormat import com.fieldbook.tracker.offbeat.traits.formats.contracts.CounterFormat import com.fieldbook.tracker.offbeat.traits.formats.contracts.DateFormat @@ -25,7 +26,7 @@ enum class Formats(val type: Types = Types.SYSTEM) { AUDIO, BOOLEAN, CAMERA, CATEGORICAL, MULTI_CATEGORICAL, COUNTER, DATE, LOCATION, NUMERIC, PERCENT, TEXT, //CUSTOM formats - DISEASE_RATING(Types.CUSTOM), GNSS(Types.CUSTOM), USB_CAMERA(Types.CUSTOM), GO_PRO(Types.CUSTOM), + DISEASE_RATING(Types.CUSTOM), GNSS(Types.CUSTOM), USB_CAMERA(Types.CUSTOM), GO_PRO(Types.CUSTOM), CANON(Types.CUSTOM), LABEL_PRINT(Types.CUSTOM), BRAPI(Types.CUSTOM); fun getTraitFormatDefinition() = when (this) { @@ -33,6 +34,7 @@ enum class Formats(val type: Types = Types.SYSTEM) { BOOLEAN -> BooleanFormat() CAMERA -> PhotoFormat() USB_CAMERA -> UsbCameraFormat() + CANON -> CanonFormat() GO_PRO -> GoProFormat() CATEGORICAL -> CategoricalFormat() MULTI_CATEGORICAL -> MultiCategoricalFormat() diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt new file mode 100644 index 000000000..5d947be33 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt @@ -0,0 +1,18 @@ +package com.fieldbook.tracker.offbeat.traits.formats.contracts + +import com.fieldbook.tracker.R +import com.fieldbook.tracker.offbeat.traits.formats.Formats +import com.fieldbook.tracker.offbeat.traits.formats.TraitFormat +import com.fieldbook.tracker.offbeat.traits.formats.parameters.DetailsParameter +import com.fieldbook.tracker.offbeat.traits.formats.parameters.NameParameter + +class CanonFormat : TraitFormat( + format = Formats.CANON, + defaultLayoutId = R.layout.trait_canon, + layoutView = null, + nameStringResourceId = R.string.traits_format_canon, + iconDrawableResourceId = R.drawable.camera_24px, + stringNameAux = null, + NameParameter(), + DetailsParameter() +) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java index 647fda1d5..7dcf3532b 100644 --- a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java +++ b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java @@ -217,6 +217,10 @@ public class GeneralKeys { @NotNull public static final Object SORT_ORDER = "com.fieldbook.tracker.field_sort_order"; + //Canon + public static final String CANON_IP = "CANON_IP"; + public static final String CANON_PORT = "CANON_PORT"; + private GeneralKeys() { } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java b/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java index 1efa127c4..1ec97235a 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/BaseTraitLayout.java @@ -93,7 +93,8 @@ public void loadLayout() { || type().equals(UsbCameraTraitLayout.type) || isTraitType(LabelPrintTraitLayout.type) || type().equals(AudioTraitLayout.type) - || type().equals(GoProTraitLayout.type)) { + || type().equals(GoProTraitLayout.type) + || type().equals(CanonTrait.type)) { toggleVisibility(View.GONE); } else { toggleVisibility(View.VISIBLE); diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt new file mode 100644 index 000000000..5484eb6ef --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt @@ -0,0 +1,422 @@ +package com.fieldbook.tracker.traits + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Point +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.DocumentsContract +import android.util.AttributeSet +import android.util.Log +import android.view.View +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.Toast +import androidx.documentfile.provider.DocumentFile +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.CollectActivity +import com.fieldbook.tracker.canon.CameraControlFactory +import com.fieldbook.tracker.canon.Controller +import com.fieldbook.tracker.database.models.ObservationModel +import com.fieldbook.tracker.dialogs.CanonConnectDialog +import com.fieldbook.tracker.preferences.GeneralKeys +import com.fieldbook.tracker.utilities.DocumentTreeUtil +import com.fieldbook.tracker.utilities.ExifUtil +import com.fieldbook.tracker.utilities.FileUtil +import com.google.android.material.floatingactionbutton.FloatingActionButton +import dagger.hilt.android.AndroidEntryPoint +import org.phenoapps.adapters.ImageAdapter +import org.phenoapps.androidlibrary.Utils +import java.io.FileNotFoundException +import javax.inject.Inject + +@AndroidEntryPoint +class CanonTrait : BaseTraitLayout, ImageAdapter.ImageItemHandler { + + @Inject + lateinit var preferences: SharedPreferences + + private val canonController by lazy { + + CameraControlFactory(PreferenceManager.getDefaultSharedPreferences(context)).create()?.let { api -> + + Controller(api) + } + } + + companion object { + const val TAG = "Canon" + const val type = "canon" + } + + private var activity: Activity? = null + private var connectBtn: FloatingActionButton? = null + private var captureBtn: FloatingActionButton? = null + private var imageView: ImageView? = null + private var recyclerView: RecyclerView? = null + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + override fun layoutId(): Int { + return R.layout.trait_canon + } + + override fun setNaTraitsText() {} + override fun type(): String { + return type + } + + override fun init(act: Activity) { + + connectBtn = act.findViewById(R.id.canon_fragment_connect_btn) + captureBtn = act.findViewById(R.id.canon_fragment_capture_btn) + imageView = act.findViewById(R.id.trait_canon_iv) + recyclerView = act.findViewById(R.id.canon_fragment_rv) + + recyclerView?.adapter = ImageAdapter(this) + + activity = act + } + + private fun setup() { + + activity?.runOnUiThread { + + captureBtn?.visibility = View.INVISIBLE + + imageView?.visibility = View.INVISIBLE + + connectBtn?.visibility = View.VISIBLE + + connectBtn?.setOnClickListener { + + CanonConnectDialog(activity!!) { + + //startConnection() + + }.show((activity as CollectActivity).supportFragmentManager, "Canon") + } + } + + canonController?.awaitConnection { + + startConnection() + + } + } + + private fun startConnection() { + + connectBtn?.visibility = View.INVISIBLE + + waitForCanonApi() + + } + + private val bridge = object : Controller.ControllerBridge { + + override fun onConnected() { + captureBtn?.visibility = View.VISIBLE + } + + override fun onStartCaptureUi() { + startCaptureUi() + } + + override fun onReceiveStreamImage(bmp: Bitmap) { + imageView?.visibility = View.VISIBLE + imageView?.setImageBitmap(bmp) + } + + override fun onFail() { + setup() + } + + override fun saveBitmap(bmp: Bitmap) { + saveBitmapToStorage(bmp) + scrollToLast() + } + } + + /** + * 1. check for device connection NUMBER_OF_CONNECTION_ATTEMPTS times + * 2. turn movie mode to off (starts still picture capture) + * 3. turn on live view (turns on lcd) + * 4. + */ + private fun waitForCanonApi() { + + canonController?.establishStream(bridge) + + } + + private fun startCaptureUi() { + + captureBtn?.visibility = View.VISIBLE + + captureBtn?.setOnClickListener { + + canonController?.postCameraShutter(bridge) + + } + } + + private fun saveBitmapToStorage(bmp: Bitmap) { + + //get current trait's trait name, use it as a plot_media directory + currentTrait.name?.let { traitName -> + + val sanitizedTraitName = FileUtil.sanitizeFileName(traitName) + + val traitDbId = currentTrait.id + + //get the bitmap from the texture view, only use it if its not null + + DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName)?.let { dir -> + + val plot = collectActivity.observationUnit + val studyId = collectActivity.studyId + val time = Utils.getDateTime() + val name = "${sanitizedTraitName}_${plot}_$time.png" + + dir.createFile("*/*", name)?.let { file -> + + context.contentResolver.openOutputStream(file.uri)?.let { output -> + + bmp.compress(Bitmap.CompressFormat.PNG, 100, output) + + database.insertObservation( + plot, traitDbId, type, file.uri.toString(), + (activity as? CollectActivity)?.person, + (activity as? CollectActivity)?.locationByPreferences, "", studyId, + null, + null, + null + ) + + //if sdk > 24, can write exif information to the image + //goal is to encode observation variable model into the user comments + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + + ExifUtil.saveJsonToExif( + context, + currentTrait, + file.uri + ) + } + } + } + + loadAdapterItems() + + } + } + } + + override fun deleteTraitListener() { + + if (!isLocked) { + + (recyclerView?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition()?.let { index -> + + if (index > -1) { + + (recyclerView?.adapter as? ImageAdapter)?.currentList?.get(index)?.let { model -> + + showDeleteImageDialog(model) + + } + } + } + } + } + + private fun showDeleteImageDialog(model: ImageAdapter.Model) { + + if (!isLocked) { + context.contentResolver.openInputStream(Uri.parse(model.uri)).use { input -> + + val imageView = ImageView(context) + imageView.setImageBitmap(BitmapFactory.decodeStream(input)) + + AlertDialog.Builder(context, R.style.AppAlertDialog) + .setTitle(R.string.delete_local_photo) + .setOnCancelListener { dialog -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { dialog, _ -> + + dialog.dismiss() + + deleteItem(model) + + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setView(imageView) + .show() + } + } + } + + private fun scrollToLast() { + + try { + + recyclerView?.postDelayed({ + + val pos = recyclerView?.adapter?.itemCount ?: 1 + + recyclerView?.scrollToPosition(pos - 1) + + }, 500L) + + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + + override fun loadLayout() { + + //slight delay to make navigation a bit faster + Handler(Looper.getMainLooper()).postDelayed({ + + loadAdapterItems() + + }, 500) + + setup() + + super.loadLayout() + } + + private fun loadAdapterItems() { + + activity?.runOnUiThread { + + val thumbnailModels = getImageObservations().mapNotNull { + + var model: ImageAdapter.Model? = null + + try { + + DocumentsContract.getDocumentThumbnail(context.contentResolver, + Uri.parse(it.value), Point(256, 256), null)?.let { bmp -> + + model = ImageAdapter.Model(it.value, bmp) + + } + + } catch (f: FileNotFoundException) { + + f.printStackTrace() + + model = null + } + + model + } + + (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) + } + } + + private fun getImageObservations(): Array { + + val traitDbId = collectActivity.traitDbId.toInt() + val plot = collectActivity.observationUnit + val studyId = collectActivity.studyId + + return database.getAllObservations(studyId).filter { + it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot + }.toTypedArray() + } + + private fun deleteItem(model: ImageAdapter.Model) { + + val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() + + val plot = currentRange.plot_id + + val traitDbId = currentTrait.id + + getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> + + try { + + DocumentFile.fromSingleUri(context, Uri.parse(observation.value)) + ?.let { image -> + + val result = image.delete() + + if (result) { + + database.deleteTraitByValue( + studyId, + plot, + traitDbId, + image.uri.toString() + ) + + loadAdapterItems() + + } else { + + collectActivity.runOnUiThread { + + Toast.makeText(context, R.string.photo_failed_to_delete, Toast.LENGTH_SHORT).show() + + } + } + } + + } catch (e: Exception) { + + Log.e(UsbCameraTraitLayout.TAG, "Failed to delete images.", e) + + } + } + } + + override fun onItemClicked(model: ImageAdapter.Model) { + + if (!isLocked) { + + getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> + + DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> + + activity?.startActivity(Intent(Intent.ACTION_VIEW, image.uri).also { + it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }) + } + } + } + } + + override fun onItemDeleted(model: ImageAdapter.Model) { + + showDeleteImageDialog(model) + } + + override fun refreshLock() { + super.refreshLock() + (context as CollectActivity).traitLockData() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java b/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java index e37cb3565..810abf6ac 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java +++ b/app/src/main/java/com/fieldbook/tracker/traits/LayoutCollections.java @@ -30,6 +30,7 @@ public LayoutCollections(Activity _activity) { traitLayouts.add(new PhotoTraitLayout(_activity)); traitLayouts.add(new UsbCameraTraitLayout(_activity)); traitLayouts.add(new GoProTraitLayout(_activity)); + traitLayouts.add(new CanonTrait(_activity)); } /** diff --git a/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt index 8956f472e..be4e76afb 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt @@ -454,7 +454,7 @@ class UsbCameraTraitLayout : BaseTraitLayout, ImageAdapter.ImageItemHandler { imageView.setImageBitmap(BitmapFactory.decodeStream(input)) AlertDialog.Builder(context, R.style.AppAlertDialog) - .setTitle(R.string.trait_usb_camera_delete_photo) + .setTitle(R.string.delete_local_photo) .setOnCancelListener { dialog -> dialog.dismiss() } .setPositiveButton(android.R.string.ok) { dialog, _ -> diff --git a/app/src/main/res/layout/dialog_canon_connection.xml b/app/src/main/res/layout/dialog_canon_connection.xml new file mode 100644 index 000000000..ed8155e7b --- /dev/null +++ b/app/src/main/res/layout/dialog_canon_connection.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/trait_canon.xml b/app/src/main/res/layout/trait_canon.xml new file mode 100644 index 000000000..9c664aa65 --- /dev/null +++ b/app/src/main/res/layout/trait_canon.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a58a808d..294faab2e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -362,10 +362,11 @@ Zoom out Zoom out Zoom in - Delete USB Photo Capture a photo. Capture + Delete Photo + Saved Size: @@ -1031,5 +1032,9 @@ Subtracts one day from the current date Adds one day to the current date Something went wrong showing the min/max of this trait format + shutter button + Canon + Canon IP Address + Canon Port \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_experimental.xml b/app/src/main/res/xml/preferences_experimental.xml index 60a1ff301..bad4fdc90 100644 --- a/app/src/main/res/xml/preferences_experimental.xml +++ b/app/src/main/res/xml/preferences_experimental.xml @@ -37,8 +37,22 @@ + android:key="experimental_settings_alpha" + android:title="@string/preferences_experimental_alpha_title" + app:iconSpaceReserved="false"> + + + + + + \ No newline at end of file From 1db6a2ece3a1e7ab79bd45ff107bec35d5869b54 Mon Sep 17 00:00:00 2001 From: chaneylc Date: Mon, 8 Jan 2024 14:22:29 -0600 Subject: [PATCH 2/6] added camera icon --- app/src/main/res/drawable/camera_24px.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/src/main/res/drawable/camera_24px.xml diff --git a/app/src/main/res/drawable/camera_24px.xml b/app/src/main/res/drawable/camera_24px.xml new file mode 100644 index 000000000..95a7ab124 --- /dev/null +++ b/app/src/main/res/drawable/camera_24px.xml @@ -0,0 +1,10 @@ + + + From e011d6bef40b5903a8599721b7981e742a2a270f Mon Sep 17 00:00:00 2001 From: chaneylc Date: Fri, 23 Feb 2024 17:16:12 -0600 Subject: [PATCH 3/6] init canon implementation --- app/src/main/AndroidManifest.xml | 8 +- .../tracker/activities/CollectActivity.java | 32 +- .../tracker/canon/BitmapConverterFactory.kt | 21 - .../tracker/canon/CameraControlApi.kt | 65 -- .../tracker/canon/CameraControlFactory.kt | 66 -- .../com/fieldbook/tracker/canon/Controller.kt | 333 ------- .../tracker/canon/models/ContentPath.kt | 5 - .../tracker/canon/models/CurrentPath.kt | 6 - .../tracker/canon/models/DeviceInformation.kt | 10 - .../tracker/canon/models/LiveViewAction.kt | 6 - .../tracker/canon/models/LiveViewSettings.kt | 6 - .../tracker/canon/models/MovieMode.kt | 5 - .../tracker/canon/models/ShutterAction.kt | 5 - .../tracker/devices/camera/CameraInterface.kt | 27 + .../tracker/devices/camera/CanonApi.kt | 917 ++++++++++++++++++ .../devices/ptpip/ChannelBufferManager.kt | 76 ++ .../tracker/devices/ptpip/Extensions.kt | 14 + .../tracker/devices/ptpip/PtpOperations.kt | 33 + .../tracker/devices/ptpip/PtpSession.kt | 377 +++++++ .../devices/ptpip/PtpSessionCallback.kt | 11 + .../tracker/dialogs/NewTraitDialog.kt | 9 +- .../tracker/interfaces/CollectController.kt | 4 + .../traits/formats/contracts/CanonFormat.kt | 8 +- .../traits/formats/contracts/GoProFormat.kt | 7 +- .../traits/formats/contracts/NumericFormat.kt | 8 +- .../traits/formats/contracts/PhotoFormat.kt | 27 +- .../formats/contracts/UsbCameraFormat.kt | 9 +- .../formats/parameters/CameraParameters.kt | 102 ++ .../traits/formats/parameters/CameraTypes.kt | 7 + .../traits/formats/parameters/Parameters.kt | 2 +- .../preferences/CanonPreferencesFragment.kt | 37 + .../tracker/preferences/GeneralKeys.java | 14 +- .../preferences/PreferencesFragment.java | 2 + .../fieldbook/tracker/traits/CameraTrait.kt | 351 +++++++ .../fieldbook/tracker/traits/CanonTrait.kt | 389 ++------ .../fieldbook/tracker/utilities/WifiHelper.kt | 158 +++ .../list_item_trait_parameter_camera.xml | 81 ++ app/src/main/res/layout/trait_canon.xml | 13 +- app/src/main/res/values/strings.xml | 12 + app/src/main/res/xml/preferences.xml | 6 + app/src/main/res/xml/preferences_canon.xml | 39 + .../main/res/xml/preferences_experimental.xml | 12 - app/src/test/java/BrapiServiceTest.java | 2 +- 43 files changed, 2420 insertions(+), 902 deletions(-) delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/Controller.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/ptpip/ChannelBufferManager.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/ptpip/Extensions.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpOperations.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSession.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSessionCallback.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraParameters.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraTypes.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/preferences/CanonPreferencesFragment.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt create mode 100644 app/src/main/res/layout/list_item_trait_parameter_camera.xml create mode 100644 app/src/main/res/xml/preferences_canon.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 63caf8962..9cd30d8af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -167,7 +167,7 @@ android:name="com.journeyapps.barcodescanner.CaptureActivity" android:screenOrientation="portrait" android:stateNotNeeded="true" - tools:replace="android:screenOrientation" /> + tools:replace="android:screenOrientation" /> + + @@ -244,6 +246,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java index d6062d913..bd01f9456 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java @@ -10,6 +10,7 @@ import android.content.res.Configuration; import android.location.Location; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.HandlerThread; @@ -48,6 +49,7 @@ import com.fieldbook.tracker.database.DataHelper; import com.fieldbook.tracker.database.models.ObservationModel; import com.fieldbook.tracker.database.models.ObservationUnitModel; +import com.fieldbook.tracker.devices.camera.CanonApi; import com.fieldbook.tracker.dialogs.GeoNavCollectDialog; import com.fieldbook.tracker.interfaces.FieldSwitcher; import com.fieldbook.tracker.location.GPSTracker; @@ -58,12 +60,14 @@ import com.fieldbook.tracker.preferences.GeneralKeys; import com.fieldbook.tracker.traits.AudioTraitLayout; import com.fieldbook.tracker.traits.BaseTraitLayout; +import com.fieldbook.tracker.traits.CanonTrait; import com.fieldbook.tracker.traits.CategoricalTraitLayout; import com.fieldbook.tracker.traits.GNSSTraitLayout; import com.fieldbook.tracker.traits.GoProTraitLayout; import com.fieldbook.tracker.traits.LayoutCollections; import com.fieldbook.tracker.traits.PhotoTraitLayout; import com.fieldbook.tracker.utilities.CategoryJsonUtil; +import com.fieldbook.tracker.utilities.DevicePairer; import com.fieldbook.tracker.utilities.DocumentTreeUtil; import com.fieldbook.tracker.utilities.FieldAudioHelper; import com.fieldbook.tracker.utilities.FieldSwitchImpl; @@ -81,6 +85,7 @@ import com.fieldbook.tracker.utilities.Utils; import com.fieldbook.tracker.utilities.VerifyPersonHelper; import com.fieldbook.tracker.utilities.VibrateUtil; +import com.fieldbook.tracker.utilities.WifiHelper; import com.fieldbook.tracker.views.CollectInputView; import com.fieldbook.tracker.views.RangeBoxView; import com.fieldbook.tracker.views.TraitBoxView; @@ -140,6 +145,12 @@ public class CollectActivity extends ThemedActivity private GeoNavHelper geoNavHelper; + @Inject + CanonApi canonApi; + + @Inject + WifiHelper wifiHelper; + @Inject KeyboardListenerHelper keyboardListenerHelper; @@ -315,6 +326,7 @@ public void handleMessage(Message msg) { checkForInitialBarcodeSearch(); verifyPersonHelper.checkLastOpened(); + } public void triggerTts(String text) { @@ -2007,19 +2019,26 @@ public void onBackPressed() { FragmentManager m = getSupportFragmentManager(); int count = getSupportFragmentManager().getBackStackEntryCount(); + String format = traitBox.getCurrentFormat(); + if (count == 0) { if (isNavigatingFromSummary) { isNavigatingFromSummary = false; - } else { + } else if (format.equals(CanonTrait.type)) { + + canonApi.stopSession(); + + wifiHelper.disconnect(); + + }else { finish(); } - } else { getSupportFragmentManager().popBackStack(); @@ -2527,4 +2546,13 @@ public void setUsbCameraConnected(boolean connected) { usbCameraConnected = connected; } + @NonNull + @Override + public CanonApi getCanonApi() { + return canonApi; + } + + @NonNull + @Override + public WifiHelper getWifiHelper() { return wifiHelper; } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt b/app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt deleted file mode 100644 index def6ea31b..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/BitmapConverterFactory.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.fieldbook.tracker.canon - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import okhttp3.ResponseBody -import retrofit2.Converter -import retrofit2.Retrofit -import java.lang.reflect.Type - -class BitmapConverterFactory : Converter.Factory() { - - override fun responseBodyConverter(type: Type, annotations: Array, retrofit: Retrofit): Converter? { - return if (type == Bitmap::class.java) { - Converter { - value -> BitmapFactory.decodeStream(value.byteStream()) - } - } else { - null - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt b/app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt deleted file mode 100644 index 4ea185bdf..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/CameraControlApi.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.fieldbook.tracker.canon - -import android.graphics.Bitmap -import com.fieldbook.tracker.canon.models.ContentPath -import com.fieldbook.tracker.canon.models.CurrentPath -import com.fieldbook.tracker.canon.models.DeviceInformation -import com.fieldbook.tracker.canon.models.LiveViewAction -import com.fieldbook.tracker.canon.models.LiveViewSettings -import com.fieldbook.tracker.canon.models.MovieMode -import com.fieldbook.tracker.canon.models.ShutterAction -import com.google.gson.JsonObject -import retrofit2.http.Body -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Path - -interface CameraControlApi { - - //Version 1.0.0 Endpoints - @GET("ver100/deviceinformation") - suspend fun getDeviceInformation(): DeviceInformation - - @POST("ver100/shooting/control/moviemode") - suspend fun postMovieMode(@Body movieMode: MovieMode): JsonObject - - @GET("ver100/shooting/control/moviemode") - suspend fun getMovieMode(): MovieMode - - @POST("ver100/shooting/control/shutterbutton") - suspend fun postShutterButton(@Body action: ShutterAction): JsonObject - - @POST("ver100/shooting/liveview") - suspend fun postLiveViewSettings(@Body settings: LiveViewSettings): JsonObject - - @POST("ver100/shooting/liveview/rtp") - suspend fun postLiveViewAction(@Body action: LiveViewAction): JsonObject - - @GET("ver100/shooting/liveview/flip") - suspend fun getLiveStream(): Bitmap - - //Version 1.1.0 Get Current Directory - - @GET("ver110/devicestatus/currentdirectory") - suspend fun getCurrentDirectory(): CurrentPath - - @GET("ver110/devicestatus/currentstorage") - suspend fun getCurrentStorage(): CurrentPath - - // Same through Version 1.0.0 -> 1.3.0 but devices implement different versions - @GET("{ver}/contents/{drive}/{dir}") - suspend fun getContents( - @Path("ver") version: String, - @Path("drive") drive: String, - @Path("dir") dir: String - ): ContentPath - - @GET("{ver}/contents/{drive}/{dir}/{name}") - suspend fun getContents( - @Path("ver") version: String, - @Path("drive") drive: String, - @Path("dir") dir: String, - @Path("name") name: String? = "" - ): Bitmap - -} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt b/app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt deleted file mode 100644 index 0e8bbf259..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/CameraControlFactory.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.fieldbook.tracker.canon - -import android.content.SharedPreferences -import com.fieldbook.tracker.preferences.GeneralKeys -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.converter.scalars.ScalarsConverterFactory -import java.util.concurrent.TimeUnit - - -class CameraControlFactory( - private val preferences: SharedPreferences -) { - - fun create(): CameraControlApi? = try { - - val ip = preferences.getString(GeneralKeys.CANON_IP, null)!! - val port = preferences.getString(GeneralKeys.CANON_PORT, null)!! - - val baseUrl = "http://$ip:$port/ccapi/" - - val client = okhttp3.OkHttpClient.Builder() - .callTimeout(5, TimeUnit.MINUTES) - .connectTimeout(5, TimeUnit.MINUTES) - .addInterceptor { chain -> - - try { - - var request = chain.request() - - val ip = preferences.getString(GeneralKeys.CANON_IP, "").toString() - val port = preferences.getString(GeneralKeys.CANON_PORT, "").toString().toInt() - - val a = request.url.newBuilder() - .host(ip) - .port(port) - .build() - - request = request.newBuilder() - .url(a) - .build() - - chain.proceed(request) - - } catch (e: Exception) { - - chain.proceed(chain.request()) - } - } - .build() - - val retrofit = Retrofit.Builder() - .client(client) - .addConverterFactory(ScalarsConverterFactory.create()) - .addConverterFactory(BitmapConverterFactory()) - .addConverterFactory(GsonConverterFactory.create()) - .baseUrl(baseUrl) - .build() - - retrofit.create(CameraControlApi::class.java) - - } catch (e: Exception) { - - null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/Controller.kt b/app/src/main/java/com/fieldbook/tracker/canon/Controller.kt deleted file mode 100644 index 5a97e02cb..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/Controller.kt +++ /dev/null @@ -1,333 +0,0 @@ -package com.fieldbook.tracker.canon - -import android.graphics.Bitmap -import android.util.Log -import com.fieldbook.tracker.canon.models.DeviceInformation -import com.fieldbook.tracker.canon.models.LiveViewSettings -import com.fieldbook.tracker.canon.models.MovieMode -import com.fieldbook.tracker.canon.models.ShutterAction -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.ConnectException - -class Controller(private val api: CameraControlApi) { - - companion object { - const val TAG = "CanonController" - private const val NUMBER_OF_CONNECTION_ATTEMPTS = 32 - } - - interface ControllerBridge { - fun onConnected() - fun onStartCaptureUi() - fun onReceiveStreamImage(bmp: Bitmap) - fun onFail() - fun saveBitmap(bmp: Bitmap) - } - - private var scope = CoroutineScope(Dispatchers.IO) - - fun establishStream(bridge: ControllerBridge) { - - scope.launch(Dispatchers.IO) { - - var response: DeviceInformation? = null - - for (i in 0..NUMBER_OF_CONNECTION_ATTEMPTS) { - - response = getDeviceInformation() - - if (response != null) break - - delay(1000L) - } - - withContext(Dispatchers.Main) { - - if (response == null) bridge.onFail() - else bridge.onConnected() - - } - - if (response != null) { - - switchCameraToStillCapture(bridge) - } - } - } - - private suspend fun switchCameraToStillCapture(bridge: ControllerBridge) { - - if (postMovieModeOff() == null) { - - withContext(Dispatchers.Main) { - - bridge.onFail() - } - - } else switchCameraPreviewOn(bridge) - - delay(1000L) - } - - private suspend fun switchCameraPreviewOn(bridge: ControllerBridge) { - - if (postLiveViewSettings() == null) { - - withContext(Dispatchers.Main) { - - bridge.onFail() - } - } else { - - withContext(Dispatchers.Main) { - bridge.onStartCaptureUi() - } - - startLiveStreamFeed(bridge) - } - - delay(1000L) - } - - private suspend fun startLiveStreamFeed(bridge: ControllerBridge) { - - while (true) { - - try { - - val bmp = api.getLiveStream() - - withContext(Dispatchers.Main) { - - bridge.onReceiveStreamImage(bmp) - - } - - } catch (e: Exception) { - - withContext(Dispatchers.Main) { - - bridge.onFail() - } - - break - - } - - delay(1000L) - } - } - - fun awaitConnection(onStartConnection: () -> Unit) { - - scope.cancel() - - scope = CoroutineScope(Dispatchers.IO) - - scope.launch { - - withContext(Dispatchers.IO) { - - while (true) { - - val response = getDeviceInformation() - - if (response != null) { - - withContext(Dispatchers.Main) { - - onStartConnection() - - } - - break - } - - delay(1000L) - } - } - } - } - - fun postCameraShutter(bridge: ControllerBridge) { - - scope.launch { - - withContext(Dispatchers.IO) { - - val postShutter = postShutterButton() - - if (postShutter != null) { - - val bmp = getLatestImageFromCanon() - - if (bmp != null) { - - bridge.saveBitmap(bmp) - } - } - } - } - } - - private suspend fun postShutterButton() = try { - - api.postShutterButton( - ShutterAction( - af = true - ) - ) - - } catch (e: ConnectException) { - - null - - } - - private suspend fun getDeviceInformation() = try { - - api.getDeviceInformation() - - } catch (e: ConnectException) { - - null - - } - - private suspend fun postMovieModeOff() = try { - - api.postMovieMode(MovieMode("off")) - - } catch (e: Exception) { - - null - - } - - private suspend fun postLiveViewSettings() = try { - - api.postLiveViewSettings( - LiveViewSettings( - "medium", - "on" - ) - ) - - } catch (e: Exception) { - - null - - } - - private suspend fun getLatestImageFromCanon(): Bitmap? { - - val storage = getCurrentStorage() - val dir = getCurrentDir() - - if (storage != null && dir != null) { - - val contents = getContents(storage.name, dir.name) - - val imagePath = contents?.path?.maxBy { - - try { - - //parse out the index in filenames: IMG_108.JPG -> 108 - val id = it.split("_")[1].split(".")[0].toInt() - - Log.d(TAG, id.toString()) - - id - - } catch (e: Exception) { - - Int.MIN_VALUE - - } - } - - val name = imagePath?.split("/")?.last() - - if (name != null) { - - return getImage(storage.name, dir.name, name) - - } - } - - return null - } - - private suspend fun getContents(drive: String, dir: String) = try { - - api.getContents("ver120", drive, dir) - - } catch (e: Exception) { - - getContentsV110(drive, dir) - } - - private suspend fun getContentsV110(drive: String, dir: String) = try { - - api.getContents("ver110", drive, dir) - - } catch (e: Exception) { - - getContentsV100(drive, dir) - } - - private suspend fun getContentsV100(drive: String, dir: String) = try { - - api.getContents("ver100", drive, dir) - - } catch (e: Exception) { - - null - - } - - private suspend fun getImage(drive: String, dir: String, name: String? = null) = try { - - api.getContents("ver120", drive, dir, name) - - } catch (e: Exception) { - - getImageV110(drive, dir) - } - - private suspend fun getImageV110(drive: String, dir: String, name: String? = null) = try { - - api.getContents("ver110", drive, dir, name) - - } catch (e: Exception) { - - getImageV100(drive, dir) - } - - private suspend fun getImageV100(drive: String, dir: String, name: String? = null) = try { - - api.getContents("ver100", drive, dir, name) - - } catch (e: Exception) { - - null - - } - - private suspend fun getCurrentStorage() = try { - api.getCurrentStorage() - } catch (e: Exception) { - null - } - - private suspend fun getCurrentDir() = try { - api.getCurrentDirectory() - } catch (e: Exception) { - null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt deleted file mode 100644 index 051586dd3..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/models/ContentPath.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.fieldbook.tracker.canon.models - -data class ContentPath( - val path: Array - ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt deleted file mode 100644 index 2b7b065b6..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/models/CurrentPath.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.fieldbook.tracker.canon.models - -data class CurrentPath( - val name: String, - val path: String - ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt deleted file mode 100644 index 480cdd04d..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/models/DeviceInformation.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.fieldbook.tracker.canon.models - -data class DeviceInformation( - val manufacturer: String, - val productname: String, - val guid: String, - val serialnumber: String, - val macaddress: String, - val firmwareversion: String -) diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt deleted file mode 100644 index df0d2faaa..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewAction.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.fieldbook.tracker.canon.models - -data class LiveViewAction( - val action: String, - val ipaddress: String - ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt deleted file mode 100644 index 412043d72..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/models/LiveViewSettings.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.fieldbook.tracker.canon.models - -data class LiveViewSettings( - var liveviewsize: String, - var cameradisplay: String - ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt deleted file mode 100644 index 6911e7368..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/models/MovieMode.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.fieldbook.tracker.canon.models - -data class MovieMode( - var action: String - ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt b/app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt deleted file mode 100644 index cf3dc81f5..000000000 --- a/app/src/main/java/com/fieldbook/tracker/canon/models/ShutterAction.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.fieldbook.tracker.canon.models - -data class ShutterAction( - val af: Boolean - ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt new file mode 100644 index 000000000..9b1fa147a --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt @@ -0,0 +1,27 @@ +package com.fieldbook.tracker.devices.camera + +import android.graphics.Bitmap + +sealed interface Device { + fun connect() + fun disconnect() +} + +interface CameraInterface: Device { + + fun startSingleShotCapture() + + fun onPreview(bmp: Bitmap) + + fun onBitmapCaptured(bmp: Bitmap) + +} + +//object DeviceAdapterFactory { +// +// fun create(): Device { +// +// +// } +// +//} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt new file mode 100644 index 000000000..0da115ded --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt @@ -0,0 +1,917 @@ +package com.fieldbook.tracker.devices.camera + +import android.content.Context +import android.graphics.BitmapFactory +import android.util.Log +import androidx.preference.PreferenceManager +import com.fieldbook.tracker.devices.ptpip.ChannelBufferManager +import com.fieldbook.tracker.devices.ptpip.PtpOperations.Companion.writeOperation +import com.fieldbook.tracker.devices.ptpip.PtpSession +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.DATA_FROM_CAMERA +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.EVENT_RESPONSE_OK +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.OP_CLOSE_SESSION +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.OP_START_SESSION +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.PACKET_TYPE_DATA +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.PACKET_TYPE_END_DATA +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.PACKET_TYPE_EVENT_REQUEST +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.PACKET_TYPE_OPERATION_REQUEST +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.PACKET_TYPE_RESPONSE +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.PACKET_TYPE_START_DATA +import com.fieldbook.tracker.devices.ptpip.PtpSession.Companion.PACKET_TYPE_START_SESSION +import com.fieldbook.tracker.devices.ptpip.PtpSessionCallback +import com.fieldbook.tracker.devices.ptpip.toByteArray +import com.fieldbook.tracker.devices.ptpip.toInt +import com.fieldbook.tracker.objects.RangeObject +import com.fieldbook.tracker.preferences.GeneralKeys +import dagger.hilt.android.qualifiers.ActivityContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.ByteArrayInputStream +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.nio.channels.SocketChannel +import javax.inject.Inject + + +class CanonApi @Inject constructor(@ActivityContext private val context: Context) { + + companion object { + const val TAG = "CanonAPI" + const val HANDLE_UPDATE_FRAME_COUNT = 60 + const val IMAGE_SIZE = 50000 + const val PHONE_NAME = "Field Book" + } + + var isConnected = false + + private var obsUnit: RangeObject? = null + private var session: PtpSession? = null + private var handles = hashMapOf() + private var capturing = false + + private val prefs by lazy { + PreferenceManager.getDefaultSharedPreferences(context) + } + + private val canonIp by lazy { + prefs.getString(GeneralKeys.CANON_IP, "192.168.1.2") + } + + private val canonPort by lazy { + prefs.getString(GeneralKeys.CANON_PORT, "15740")?.toInt() ?: 15740 + } + + private val isDebug by lazy { + prefs.getBoolean(GeneralKeys.CANON_DEBUG, false) + } + + private fun log(message: String) { + if (isDebug) { + Log.d(TAG, message) + } + } + + fun stopSession() { + + isConnected = false + + session?.let { session -> + + requestStopSession(session) + + } + } + + fun initiateSession(sessionCallback: PtpSessionCallback) { + + if (isConnected) { + resumeSession(sessionCallback) + } else { + isConnected = true + startNewSession(sessionCallback) + } + } + + private fun startSession( + chanMan: ChannelBufferManager, + eventChanMan: ChannelBufferManager, + sessionCallback: PtpSessionCallback + ) { + + val session = PtpSession(chanMan, eventChanMan, sessionCallback) + + this.session = session + + requestStartSession(session) + } + + private fun requestStartSession(session: PtpSession) { + + session.callbacks?.onSessionStart() + + log("Starting session") + + session.transaction({ tid -> + + writeOperation( + session.comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_START_SESSION, + tid, + session.sessionId + ) + + }) { response -> + + log("Starting session responded $response") + + if (response) { + + requestRemoteMode(session) + } + } + } + + private fun requestStopSession(session: PtpSession) { + + log("Stopping session") + + val scope = CoroutineScope(Dispatchers.IO) + + scope.launch { + + try { + + session.transaction({ tid -> + + writeOperation( + session.comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_CLOSE_SESSION, + tid, + session.sessionId + ) + + }) { response -> + + log("Stopping session responded $response") + + session.callbacks?.onSessionStop() + + session.disconnect() + + } + + } catch (_: Exception) { + } + } + } + + private fun verifyInitCommandAckPacket(chanMan: ChannelBufferManager): ByteArray { + + val length = chanMan.getInt() + + val data = chanMan.getBytes(length - Int.SIZE_BYTES) + + return data.slice(4..7).toByteArray() + } + + private fun readEventType(session: PtpSession, eventBytes: Int, eventType: Int) { + + val propCode = session.comMan.getInt() + + session.comMan.getBytes(eventBytes - 12) + + } + + private fun readEventData(session: PtpSession) { + + var total = 0 + + do { + + val eventBytes = session.comMan.getInt() + + total += eventBytes + + val eventType = session.comMan.getInt() + + if (eventBytes == 8 && eventType == 0) { + + break + + } + + readEventType(session, eventBytes, eventType) + + if (eventType == PACKET_TYPE_RESPONSE) break + + } while (true) + } + + private fun writeParameter1(session: PtpSession, storageId: ByteArray) { + + log("Writing parameter d136, required for live view") + + session.transaction({ tid -> + + val cameraModeDataLength = Int.SIZE_BYTES + val cameraModeData = byteArrayOf(0x36, 0xd1.toByte(), 0x0, 0x0, 0x0, 0x0, 0x0, 0x0) + + session.writePropertyValue(tid) + + session.writeStartDataPacket(cameraModeDataLength + cameraModeData.size, tid) + + session.writeEndDataPacket(cameraModeData, tid) + + }) { + + log("Writing parameter d136 response: $it") + + writeParameter2(session, storageId) + } + } + + private fun writeParameter2(session: PtpSession, storageId: ByteArray) { + + log("Writing parameter d1b0 required for live view") + + session.transaction({ tid -> + + val cameraMode2Length = 4 + val cameraMode2Data = + byteArrayOf(0xb0.toByte(), 0xd1.toByte(), 0x0, 0x0, 0x09, 0x0, 0x0, 0x0) + + session.writePropertyValue(tid) + + session.writeStartDataPacket(cameraMode2Length + cameraMode2Data.size, tid) + + session.writeEndDataPacket(cameraMode2Data, tid) + + }) { response -> + + log("d1b0 response $response") + + if (response) { + + startMainLoop(session, storageId) + } + } + } + + private fun writeRequest(channel: SocketChannel, packetType: Int, params: ByteArray) { + + val packet = packetType.toByteArray() + + val length = (Int.SIZE_BYTES + packet.size + params.size).toByteArray() + + val payload = ByteBuffer.wrap( + length + + packet + + params + ) + + channel.write(payload) + + channel.socket().getOutputStream().flush() + } + + private fun writeLiveViewParameters(session: PtpSession, storageId: ByteArray) { + + writeParameter1(session, storageId) + + } + + private fun startNewSession(sessionCallback: PtpSessionCallback) { + + //TODO potentially add these timeouts to preferences + val command: SocketChannel = SocketChannel.open(InetSocketAddress(canonIp, canonPort)) + command.socket().soTimeout = 10000 + command.socket().keepAlive = true + command.socket().tcpNoDelay = true + command.socket().reuseAddress = true + + val chanMan = ChannelBufferManager(command) + + val connectionNumber = requestConnection(chanMan) + + val events = SocketChannel.open(InetSocketAddress(canonIp, canonPort)) + events.socket().soTimeout = 120000 + events.socket().keepAlive = true + events.socket().tcpNoDelay = true + + val eventChanMan = ChannelBufferManager(events) + + writeRequest(events, PACKET_TYPE_EVENT_REQUEST, connectionNumber) + + if (verifyEventResponse(eventChanMan)) { + + startSession(chanMan, eventChanMan, sessionCallback) + + } + } + + private fun resumeSession(sessionCallbacks: PtpSessionCallback) { + + this.session?.callbacks = sessionCallbacks + + this.session?.callbacks?.onSessionStart() + + } + + private fun requestConnection(chanMan: ChannelBufferManager): ByteArray { + + val uuid = ByteArray(16) { 0x0 } + + val nameBytes = PHONE_NAME.toByteArray(Charsets.US_ASCII) + val data = nameBytes + .zip(ByteArray(nameBytes.size) { 0x00 }) + .flatMap { (x, y) -> listOf(x, y) } + .toByteArray() + byteArrayOf(0x00, 0x00) + + val version = byteArrayOf(0x0, 0x0, 0x01, 0x0) + + writeRequest(chanMan.channel, PACKET_TYPE_START_SESSION, uuid + data + version) + + return verifyInitCommandAckPacket(chanMan) + } + + private fun requestGetImage(session: PtpSession, handle: ByteArray, unit: RangeObject) { + + log("Requesting get image") + + session.transaction({ tid -> + + session.writeGetImage(handle, tid) + + val payloadSize = awaitStartPacket(session) + + while (true) { + + val (packetType, data) = awaitImageEndPacket(session) + + if (payloadSize >= IMAGE_SIZE) { + + log("Found image") + + try { + + val bmp = BitmapFactory.decodeStream(ByteArrayInputStream(data)) + + session.callbacks?.onBitmapCaptured(bmp, unit) + + } catch (ignore: Exception) { + } + } + + if (packetType == 12) break + } + + }) { response -> + + log("Response get image $response") + + } + } + + private fun startMainLoop( + session: PtpSession, + storageId: ByteArray + ) { + + log("Starting main loop") + + var frame = HANDLE_UPDATE_FRAME_COUNT / 2 + + while (isConnected) { + + if (capturing) { + + capturing = false + + startCapture(session) + + requestUpdateHandles(session, storageId) + + continue + + } else { + + requestLiveViewImage(session) + + if (frame == 0) { + + requestUpdateHandles(session, storageId) + + } + + frame = (frame + 1) % HANDLE_UPDATE_FRAME_COUNT + + //Thread.sleep(1000/24) + Thread.sleep(1000 / 60) + + } + } + } + + private fun requestUpdateHandles(session: PtpSession, storageId: ByteArray) { + + log("Requesting update image handles") + + var newHandles: Array? = null + + session.transaction({ tid -> + + session.writeGetObjectHandles(storageId, tid) + + val size = awaitStartPacket(session) + + val handles = awaitEndPacketHandles(session) + + newHandles = handles.toTypedArray() + + }) { response -> + + log("Response update image handles $response") + + if (response) { + + var newImage = handles.isNotEmpty() + + newHandles?.forEach { handle -> + + val handleInteger = handle.toInt() + + if (newImage) { + + if (handleInteger !in handles) { + + obsUnit?.let { unit -> + + log("Found new image $handleInteger for ${unit.plot_id}") + + requestGetImage(session, handle, unit) + + } + } + } + + handles[handleInteger] = handle + } + } + } + } + + private fun requestLiveViewImage(session: PtpSession) { + + log("Requesting live view image") + + session.transaction({ tid -> + + session.writeGetLiveView(tid) + + var payloadSize = awaitStartPacket(session) + + var total = 0 + + while (true) { + + val (size, type) = awaitDataPacket(session) + + if (type == 12) break + + total += size + + } + + }) { response -> + + log("Response live view image $response") + } + } + + private fun requestStartImageRelease(session: PtpSession) { + + log("Requesting start image release") + + session.transaction({ tid -> + + session.writeStartImageRelease(tid) + + }) { response -> + + log("Start image release response $response") + + requestStopImageRelease(session) + + } + } + + private fun requestStopAutoFocus(session: PtpSession) { + + log("Request stop auto focus") + + session.transaction({ tid -> + + session.writeStopAutoFocus(tid) + + }) { response -> + + log("Response stop auto focus $response") + } + } + + private fun requestStopImageRelease(session: PtpSession) { + + log("Requesting stop image release") + + session.transaction({ tid -> + + session.writeStopImageRelease(tid) + + }) { response -> + + log("Stop image release response: $response") + + requestStopAutoFocus(session) + + } + } + + private fun requestRemoteReleaseOff(session: PtpSession) { + + log("Requesting remote release off") + + session.transaction({ tid -> + + session.writeRemoteReleaseOff(tid) + + }) { response -> + + log("Remote release response $response") + + requestStartImageRelease(session) + + } + } + + private fun requestRemoteReleaseOn(session: PtpSession) { + + log("Requesting remote release on") + + session.transaction({ tid -> + + session.writeRemoteReleaseOn(tid) + + }) { response -> + + log("Remote release response $response") + + requestRemoteReleaseOff(session) + + } + } + + private fun startCapture(session: PtpSession) { + + log("Starting capture") + + session.transaction({ tid -> + + session.writeStartShootingMode(tid) + + }) { response -> + + log("Capture response $response") + + requestRemoteReleaseOn(session) + + } + } + + private fun verifyEventResponse(chanMan: ChannelBufferManager): Boolean { + + chanMan.channel.socket().getOutputStream().flush() + + val length = chanMan.getInt() + + val response = chanMan.getBytes(length - Int.SIZE_BYTES) + + val data = response.toInt() + + return data == EVENT_RESPONSE_OK + + } + + fun startSingleShotCapture(unit: RangeObject) { + + capturing = true + + obsUnit = unit + + } + + private fun initializeHandleCache(session: PtpSession, storageId: ByteArray) { + + log("Initializing image handles") + + var newHandles: Array? = null + + session.transaction({ tid -> + + session.writeGetObjectHandles(storageId, tid) + + val size = awaitStartPacket(session) + + val handles = awaitEndPacketHandles(session) + + newHandles = handles.toTypedArray() + + }) { response -> + + if (response) { + + log("Found handles: ${newHandles?.toList()}") + + newHandles?.forEach { handle -> + + val handleInteger = handle.toInt() + + handles[handleInteger] = handle + } + + writeLiveViewParameters(session, storageId) + } + } + } + + private fun requestRemoteMode(session: PtpSession) { + + log("Requesting remote mode") + + session.transaction({ tid -> + + session.writeRemoteMode(tid) + + }) { response -> + + if (response) { + + log("Remote mode responded with OK") + + requestEventMode(session) + } + } + } + + private fun requestStorageIds(session: PtpSession) { + + log("Requesting storage IDs") + + var storageId: ByteArray? = null + + session.transaction({ tid -> + + session.writeGetStorageIds(tid) + + var size = awaitStartPacket(session) + + val storageIds = awaitEndDataHandlesPacket(session) + + storageId = storageIds.first() + + }) { response -> + + if (response && storageId != null) { + + log("Found storage id: ${storageId?.toList()}") + + initializeHandleCache(session, storageId!!) + + } + } + } + + private fun requestEventMode(session: PtpSession) { + + log("Requesting event mode") + + session.transaction({ tid -> + + session.writeEventMode(tid) + + }) { response -> + + if (response) { + + log("Event mode responded with OK") + + requestStorageIds(session) + } + } + } + + private fun awaitStartPacket(session: PtpSession): Int { + + var (length, packetType) = readDataPackets(session) + + while (packetType == PACKET_TYPE_RESPONSE) { + val (l, p) = readDataPackets(session) + length = l + packetType = p + } + + return length + } + + private fun awaitEndDataHandlesPacket(session: PtpSession): List { + + val handles = arrayListOf() + + val endDataPacketSize = session.comMan.getInt() + + val packetType = session.comMan.getInt() + + val tid = session.comMan.getInt() + + val numStorageIds = session.comMan.getInt() + + for (i in 0.. { + + val handles = arrayListOf() + + val endDataPacketSize = session.comMan.getInt() + + val packetType = session.comMan.getInt() + + val tid = session.comMan.getInt() + + val numStorageIds = session.comMan.getInt() + + for (i in 0.. { + + val length = session.comMan.getInt() + + val packetType = session.comMan.getInt() + + if (packetType == PACKET_TYPE_RESPONSE) { + + val op = session.comMan.getShort() + + val tid = session.comMan.getInt() + + } else if (packetType == PACKET_TYPE_START_DATA) { + + val tid = session.comMan.getInt() + + val totalDataLength = session.comMan.getLong().toInt() + + return totalDataLength to packetType + + } else if (packetType == PACKET_TYPE_DATA) { + + val tid = session.comMan.getInt() + + val totalBytesToRead = length - 12 //+ final - 12 + 10 + + val reader = session.comMan.channel.socket().getInputStream() + + val data = ByteArray(totalBytesToRead) + var index = 0 + while (index < totalBytesToRead) { + + val bytes = reader.read(data, index, totalBytesToRead - index) + + index += bytes + } + + if (totalBytesToRead >= IMAGE_SIZE) { + + val bmp = try { + BitmapFactory.decodeStream(ByteArrayInputStream(data)) + } catch (e: Exception) { + null + } + + if (bmp != null) session.callbacks?.onPreview(bmp) + } + + } else if (packetType == PACKET_TYPE_END_DATA) { + + readEventData(session) + + } + + return length to packetType + } + + private fun awaitDataPacket(session: PtpSession): Pair { + + val endDataPacketSize = session.comMan.getInt() + + val packetTypeInt = session.comMan.getInt() + + val tid = session.comMan.getInt() + + if (packetTypeInt == PACKET_TYPE_DATA) { + + val totalBytesToRead = endDataPacketSize - 12 + val reader = session.comMan.channel.socket().getInputStream() + + val data = ByteArray(totalBytesToRead) + var index = 0 + while (index < totalBytesToRead) { + + val bytes = reader.read(data, index, totalBytesToRead - index) + + index += bytes + } + + if (totalBytesToRead >= IMAGE_SIZE) { + + val bmp = try { + BitmapFactory.decodeStream(ByteArrayInputStream(data)) + } catch (e: Exception) { + null + } + + if (bmp != null) session.callbacks?.onPreview(bmp) + + } + + } else if (packetTypeInt == 12) { + + session.comMan.getInt() + + session.comMan.getInt() + + } else { + + log("Something went wrong...debugging to log file") + + if (isDebug) { + + session.debugBuffer(context) + + } + + throw Exception() + } + + return Pair(endDataPacketSize, packetTypeInt) + } + + private fun awaitImageEndPacket(session: PtpSession): Pair { + + var data = byteArrayOf() + + val endDataPacketSize = session.comMan.getInt() + + val packetTypeInt = session.comMan.getInt() + + val tid = session.comMan.getInt() + + val totalBytesToRead = endDataPacketSize - 12 + + if (packetTypeInt == PACKET_TYPE_END_DATA) { + + data = session.comMan.getBitmap(totalBytesToRead) + + } else { + + log("Something went wrong... starting debug log") + + if (isDebug) { + + session.debugBuffer(context) + + } + + throw Exception() + } + + return packetTypeInt to data + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/ptpip/ChannelBufferManager.kt b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/ChannelBufferManager.kt new file mode 100644 index 000000000..22cf73f85 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/ChannelBufferManager.kt @@ -0,0 +1,76 @@ +package com.fieldbook.tracker.devices.ptpip + +import java.nio.ByteBuffer +import java.nio.channels.SocketChannel + +class ChannelBufferManager(val channel: SocketChannel) { + + private val longBuffer = ByteBuffer.allocate(Long.SIZE_BYTES) + + private val intBuffer = ByteBuffer.allocate(Int.SIZE_BYTES) + + private val shortBuffer = ByteBuffer.allocate(Short.SIZE_BYTES) + + fun getInt(): Int { + + channel.read(intBuffer) + + val value = intBuffer.toInt() + + intBuffer.clear() + + return value + } + + fun getShort(): Int { + + channel.read(shortBuffer) + + val value = shortBuffer.toInt() + + shortBuffer.clear() + + return value + } + + fun getLong(): Long { + + channel.read(longBuffer) + + val value = longBuffer.toLong() + + longBuffer.clear() + + return value + } + + fun getBytes(length: Int): ByteArray { + + val data = ByteBuffer.allocate(length) + + channel.read(data) + + val bytes = data.array().copyOf() + + data.clear() + + return bytes + } + + fun getBitmap(length: Int): ByteArray { + + val reader = channel.socket().getInputStream() + + val data = ByteArray(length) + var index = 0 + while (index < length) { + + val bytes = reader.read(data, index, length - index) + + index += bytes + } + + return data + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/ptpip/Extensions.kt b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/Extensions.kt new file mode 100644 index 000000000..b86f8c3d9 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/Extensions.kt @@ -0,0 +1,14 @@ +package com.fieldbook.tracker.devices.ptpip + +import java.nio.ByteBuffer +import java.nio.ByteOrder + +fun Long.toByteArray() = ByteBuffer.allocate(Long.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).putLong(this).array() +fun Int.toByteArray() = ByteBuffer.allocate(UInt.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(this).array() //.array().joinToString(",") { "0x%02x".format(it) }) +fun Short.toByteArray() = ByteBuffer.allocate(Short.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).putShort(this).array() +fun ByteArray.toInt(): Int = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN).getInt() +fun ByteArray.toShort(): Short = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN).getShort() +fun ByteArray.toLong(): Long = ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN).getLong() +fun ByteBuffer.toInt() = array().toInt() +fun ByteBuffer.toShort() = array().toShort() +fun ByteBuffer.toLong() = array().toLong() \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpOperations.kt b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpOperations.kt new file mode 100644 index 000000000..5826a0e52 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpOperations.kt @@ -0,0 +1,33 @@ +package com.fieldbook.tracker.devices.ptpip + +import java.nio.ByteBuffer +import java.nio.channels.SocketChannel + +class PtpOperations { + + companion object { + + fun writeOperation(command: SocketChannel, packetType: Int, dataPhase: Int, opCode: Short, tid: Int, params: ByteArray) { + + val packet = packetType.toByteArray() + val data = dataPhase.toByteArray() + val op = opCode.toByteArray() + val id = tid.toByteArray() + + val length = (Int.SIZE_BYTES + packet.size + data.size + op.size + id.size + params.size).toByteArray() + + val payload = ByteBuffer.wrap( + length + + packet + + data + + op + + id + + params + ) + + command.write(payload) + + command.socket().getOutputStream().flush() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSession.kt b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSession.kt new file mode 100644 index 000000000..1674b7be9 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSession.kt @@ -0,0 +1,377 @@ +package com.fieldbook.tracker.devices.ptpip + +import android.content.Context +import androidx.core.net.toUri +import java.io.File +import java.io.OutputStreamWriter +import java.nio.ByteBuffer + +class PtpSession(val comMan: ChannelBufferManager, + val eventMan: ChannelBufferManager, + var callbacks: PtpSessionCallback?) { + + private var tid = 0 + + //PowerShot v10 only goes into remote mode if session id starts here + val sessionId = byteArrayOf(0x41, 0x0, 0x0, 0x0) + + companion object { + + const val PACKET_TYPE_OPERATION_REQUEST = 0x06 + const val PACKET_TYPE_START_DATA = 0x09 + const val PACKET_TYPE_END_DATA = 0x0c + const val PACKET_TYPE_DATA = 0x0a + const val PACKET_TYPE_EVENT_REQUEST = 0x03 + const val PACKET_TYPE_START_SESSION = 0x01 + const val PACKET_TYPE_RESPONSE = 0x07 + + const val EVENT_RESPONSE_OK = 0x04 + const val COMMAND_RESPONSE_OK = 0x07 + + const val DATA_FROM_CAMERA = 0x01 + const val DATA_TO_CAMERA = 0x02 + + const val OP_START_SESSION: Short = 0x1002 + const val OP_CLOSE_SESSION: Short = 0x1003 + const val OP_GET_LIVE_VIEW: Short = 0x9153.toShort() + const val OP_REMOTE_MODE: Short = 0x9114.toShort() + const val OP_EVENT_MODE: Short = 0x9115.toShort() + const val OP_SET_PROP_VALUE: Short = 0x9110.toShort() + const val OP_902f: Short = 0x902f.toShort() + const val OP_GET_OBJECT_HANDLES: Short = 0x1007.toShort() + const val OP_GET_STORAGE_IDS: Short = 0x9101.toShort() + const val OP_GET_IMAGE: Short = 0x9172.toShort() + } + + private fun nextId() = try { + ++tid + } catch (e: Exception) { + tid = 0 + ++tid + } + + fun request(operations: (tid: Int) -> Unit) { + + operations.invoke(tid) + + } + + fun transaction(operation: (tid: Int) -> Unit, onComplete: (Boolean) -> Unit) { + + val tid = nextId() + + operation.invoke(tid) + + onComplete(verifyResponse(comMan)) + } + + fun disconnect() { + + comMan.channel.close() + + eventMan.channel.close() + + callbacks = null + + } + + private fun verifyResponse(chanMan: ChannelBufferManager): Boolean { + + chanMan.channel.socket().getOutputStream().flush() + + val length = chanMan.getInt() + + val response = chanMan.getBytes(length - Int.SIZE_BYTES) + + val data = response.slice(4..5).toByteArray().toShort() + + return data == 0x2001.toShort() + + } + + fun writeGetImage(handle: ByteArray, tid: Int) { + + PtpOperations.writeOperation( + comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_GET_IMAGE, + tid, + handle + byteArrayOf( + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x20, 0x00, + 0x00, 0x00, 0x00, 0x00 + ) + ) + } + + fun writeGetObjectHandles(storageId: ByteArray, tid: Int) { + + PtpOperations.writeOperation( + comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_GET_OBJECT_HANDLES, + tid, + storageId + ) + } + + fun writeGetStorageIds(tid: Int) { + + PtpOperations.writeOperation( + comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_GET_STORAGE_IDS, + tid, + byteArrayOf() + ) + } + + fun writeRemoteMode(tid: Int) { + + PtpOperations.writeOperation( + comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_REMOTE_MODE, + tid, + byteArrayOf(0x15, 0x0, 0x0, 0x0) + ) + } + + fun writeEventMode(tid: Int) { + + PtpOperations.writeOperation( + comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_EVENT_MODE, + tid, + byteArrayOf(0x02, 0x0, 0x0, 0x0) + ) + } + + fun writeRemoteReleaseOn(tid: Int) { + + comMan.channel.write( + ByteBuffer.wrap( + byteArrayOf( + 0x1a, 0x00, 0x0, 0x0, + 0x06, 0x00, 0x00, 0x0, //packet type: operation request + 0x01, 0x00, 0x00, 0x0, //data phase: no data + 0x28, 0x91.toByte() + ) + + tid.toByteArray() + + byteArrayOf( + 0x03, 0x00, 0x0, 0x0, + 0x0, 0x0, 0x0, 0x0, + ) + ) + ) + } + + fun writeRemoteReleaseOff(tid: Int) { + + comMan.channel.write( + ByteBuffer.wrap( + byteArrayOf( + 0x16, 0x00, 0x0, 0x0, + 0x06, 0x00, 0x00, 0x0, //packet type: operation request + 0x01, 0x00, 0x00, 0x0, //data phase: no data + 0x29, 0x91.toByte() + ) + + tid.toByteArray() + + byteArrayOf( + 0x03, 0x00, 0x0, 0x0, + ) + ) + ) + } + + fun writeStartShootingMode(tid: Int) { + + comMan.channel.write( + ByteBuffer.wrap( + byteArrayOf( + 0x1a, 0x00, 0x0, 0x0, + 0x06, 0x00, 0x00, 0x0, //packet type: operation request + 0x01, 0x00, 0x00, 0x0, //data phase: no data + 0x08, 0x90.toByte() + ) + + tid.toByteArray() + + byteArrayOf( + 0x01, 0x00, 0x0, 0x0, // capture phase focus 0x1 + 0x0, 0x0, 0x0, 0x0, // unknown + ) + ) + ) + } + + fun writeStartImageRelease(tid: Int) { + + comMan.channel.write( + ByteBuffer.wrap( + byteArrayOf( + 0x1a, 0x00, 0x0, 0x0, + 0x06, 0x00, 0x00, 0x0, //packet type: operation request + 0x01, 0x00, 0x00, 0x0, //data phase: no data + 0x28, 0x91.toByte() + ) + + tid.toByteArray() + + byteArrayOf(// open session (0x1002) + 0x02, 0x00, 0x0, 0x0, // capture phase release 0x02 + 0x0, 0x0, 0x0, 0x0 //unknown property + ) + ) + ) + } + + fun writeStopImageRelease(tid: Int) { + + comMan.channel.write( + ByteBuffer.wrap( + byteArrayOf( + 0x16, 0x00, 0x0, 0x0, + 0x06, 0x00, 0x00, 0x0, //packet type: operation request + 0x01, 0x00, 0x00, 0x0, //data phase: no data + 0x29, 0x91.toByte() + ) + + tid.toByteArray() + + byteArrayOf(// open session (0x1002) + 0x02, 0x00, 0x0, 0x0 // capture phase release + ) + ) + ) + } + + fun writeStopAutoFocus(tid: Int) { + + comMan.channel.write( + ByteBuffer.wrap( + byteArrayOf( + 0x16, 0x00, 0x0, 0x0, + 0x06, 0x00, 0x00, 0x0, //packet type: operation request + 0x01, 0x00, 0x00, 0x0, //data phase: no data + 0x29, 0x91.toByte() + ) + + tid.toByteArray() + + byteArrayOf(// open session (0x1002) + 0x02, 0x00, 0x0, 0x0 // capture phase focus + ) + ) + ) + } + + fun writeGetLiveView(tid: Int) { + + PtpOperations.writeOperation( + comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_FROM_CAMERA, + OP_GET_LIVE_VIEW, + tid, + byteArrayOf( + 0x0, 0x0, 0x20, 0x00, //param + 0x1, 0x0, 0x0, 0x0, //param + 0x0, 0x0, 0x0, 0x0 + ) + ) + } + + fun writePropertyValue(tid: Int) { + + PtpOperations.writeOperation( + comMan.channel, + PACKET_TYPE_OPERATION_REQUEST, + DATA_TO_CAMERA, + OP_SET_PROP_VALUE, + tid, + byteArrayOf() + ) + } + + fun writeStartDataPacket(totalDataLength: Int, tid: Int) { + + val packet = PACKET_TYPE_START_DATA.toByteArray() + val id = tid.toByteArray() + val totalData = totalDataLength.toLong().toByteArray() + + val length = (4 + packet.size + id.size + totalData.size).toByteArray() + + val payload = ByteBuffer.wrap( + length + + packet + + id + + totalData + ) + + comMan.channel.write(payload) + + } + + fun writeEndDataPacket(data: ByteArray, tid: Int) { + + val packet = PACKET_TYPE_END_DATA.toByteArray() + val id = tid.toByteArray() + + val length = (Int.SIZE_BYTES + Int.SIZE_BYTES + packet.size + id.size + data.size).toByteArray() + + val eventLength = (Int.SIZE_BYTES + data.size).toByteArray() + + val payload = ByteBuffer.wrap( + length + + packet + + id + + eventLength + + data + ) + + comMan.channel.write(payload) + + } + + fun debugBuffer(context: Context) { + + try { + + val output = File(context.externalCacheDir, "//Log.txt") + + context.contentResolver.openOutputStream(output.toUri(), "wa").use { stream -> + + val writer = OutputStreamWriter(stream) + + val intBuffer = ByteBuffer.allocate(16) + + try { + + var i = 1 + + while (true) { + + comMan.channel.read(intBuffer) + + writer.write(intBuffer.array().joinToString(", ") { "0x%02x".format(it) } + .split(", ").chunked(4).joinToString("\n") { "$it" }) + + writer.write("\n") + writer.flush() + + intBuffer.clear() + + i++ + } + + } catch (_: Exception) {} + + writer.flush() + + writer.close() + + stream?.close() + } + + } catch (_: Exception) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSessionCallback.kt b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSessionCallback.kt new file mode 100644 index 000000000..b5aa4cce3 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/ptpip/PtpSessionCallback.kt @@ -0,0 +1,11 @@ +package com.fieldbook.tracker.devices.ptpip + +import android.graphics.Bitmap +import com.fieldbook.tracker.objects.RangeObject + +interface PtpSessionCallback { + fun onSessionStart() + fun onSessionStop() + fun onPreview(bmp: Bitmap) + fun onBitmapCaptured(bmp: Bitmap, obsUnit: RangeObject) +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt b/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt index 28dbbf059..34eb1ed29 100644 --- a/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt +++ b/app/src/main/java/com/fieldbook/tracker/dialogs/NewTraitDialog.kt @@ -6,6 +6,7 @@ import android.app.Dialog import android.content.Context import android.content.DialogInterface import android.content.SharedPreferences +import android.graphics.Camera import android.os.Bundle import android.view.View import android.view.WindowManager @@ -24,6 +25,8 @@ import com.fieldbook.tracker.objects.TraitObject import com.fieldbook.tracker.offbeat.traits.formats.Formats import com.fieldbook.tracker.offbeat.traits.formats.TraitFormatParametersAdapter import com.fieldbook.tracker.offbeat.traits.formats.ValidationResult +import com.fieldbook.tracker.offbeat.traits.formats.contracts.PhotoFormat +import com.fieldbook.tracker.offbeat.traits.formats.parameters.CameraTypes import com.fieldbook.tracker.offbeat.traits.formats.ui.ParameterScrollView import com.fieldbook.tracker.preferences.GeneralKeys import com.fieldbook.tracker.utilities.SoundHelperImpl @@ -268,7 +271,11 @@ class NewTraitDialog( traitFormatsRv.adapter = formatsAdapter - formatsAdapter.submitList(Formats.values().toList()) + formatsAdapter.submitList(Formats.entries.filter { format -> + format !in CameraTypes.entries + .filter { it.name != CameraTypes.DEFAULT.name } + .map { it.format } + }) } } diff --git a/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt b/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt index 4aa13d44e..06d3dd6e4 100644 --- a/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt +++ b/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt @@ -3,11 +3,13 @@ package com.fieldbook.tracker.interfaces import android.content.Context import android.location.Location import android.os.Handler +import com.fieldbook.tracker.devices.camera.CanonApi import com.fieldbook.tracker.location.GPSTracker import com.fieldbook.tracker.utilities.GeoNavHelper import com.fieldbook.tracker.utilities.GnssThreadHelper import com.fieldbook.tracker.utilities.SoundHelperImpl import com.fieldbook.tracker.utilities.VibrateUtil +import com.fieldbook.tracker.utilities.WifiHelper import com.fieldbook.tracker.views.CollectInputView import com.fieldbook.tracker.views.RangeBoxView import com.fieldbook.tracker.views.TraitBoxView @@ -39,4 +41,6 @@ interface CollectController: FieldController { ) : String fun getGeoNavPopupSpinnerItems(): ArrayList fun logNmeaMessage(nmea: String) + fun getCanonApi(): CanonApi + fun getWifiHelper(): WifiHelper } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt index 5d947be33..823ef7e66 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt @@ -1,18 +1,16 @@ package com.fieldbook.tracker.offbeat.traits.formats.contracts +import android.provider.ContactsContract.Contacts.Photo import com.fieldbook.tracker.R import com.fieldbook.tracker.offbeat.traits.formats.Formats import com.fieldbook.tracker.offbeat.traits.formats.TraitFormat import com.fieldbook.tracker.offbeat.traits.formats.parameters.DetailsParameter import com.fieldbook.tracker.offbeat.traits.formats.parameters.NameParameter -class CanonFormat : TraitFormat( +class CanonFormat : PhotoFormat( format = Formats.CANON, defaultLayoutId = R.layout.trait_canon, layoutView = null, nameStringResourceId = R.string.traits_format_canon, iconDrawableResourceId = R.drawable.camera_24px, - stringNameAux = null, - NameParameter(), - DetailsParameter() -) \ No newline at end of file + stringNameAux = null) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/GoProFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/GoProFormat.kt index 1e6b641c7..94dae5c28 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/GoProFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/GoProFormat.kt @@ -1,18 +1,17 @@ package com.fieldbook.tracker.offbeat.traits.formats.contracts +import android.provider.ContactsContract.Contacts.Photo import com.fieldbook.tracker.R import com.fieldbook.tracker.offbeat.traits.formats.Formats import com.fieldbook.tracker.offbeat.traits.formats.TraitFormat import com.fieldbook.tracker.offbeat.traits.formats.parameters.DetailsParameter import com.fieldbook.tracker.offbeat.traits.formats.parameters.NameParameter -class GoProFormat : TraitFormat( +class GoProFormat : PhotoFormat( format = Formats.GO_PRO, defaultLayoutId = R.layout.trait_go_pro, layoutView = null, nameStringResourceId = R.string.traits_format_go_pro_camera, iconDrawableResourceId = R.drawable.ic_trait_gopro, - stringNameAux = null, - NameParameter(), - DetailsParameter() + stringNameAux = null ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/NumericFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/NumericFormat.kt index 184e3f50a..9c470f2ae 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/NumericFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/NumericFormat.kt @@ -104,8 +104,8 @@ open class NumericFormat( result = false - maxParameter.textInputLayout.endIconDrawable = null - minParameter.textInputLayout.endIconDrawable = null + maxParameter.textInputLayout?.endIconDrawable = null + minParameter.textInputLayout?.endIconDrawable = null maxParameter.numericEt.error = magnitudeRelationError minParameter.numericEt.error = magnitudeRelationError @@ -114,7 +114,7 @@ open class NumericFormat( result = false - minParameter.textInputLayout.endIconDrawable = null + minParameter.textInputLayout?.endIconDrawable = null defaultParameter?.textInputLayout?.endIconDrawable = null minParameter.numericEt.error = magnitudeRelationError @@ -124,7 +124,7 @@ open class NumericFormat( result = false - maxParameter.textInputLayout.endIconDrawable = null + maxParameter.textInputLayout?.endIconDrawable = null defaultParameter?.textInputLayout?.endIconDrawable = null maxParameter.numericEt.error = magnitudeRelationError diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/PhotoFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/PhotoFormat.kt index b676691e2..75bf68462 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/PhotoFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/PhotoFormat.kt @@ -1,18 +1,29 @@ package com.fieldbook.tracker.offbeat.traits.formats.contracts +import android.content.Context +import android.view.View import com.fieldbook.tracker.R import com.fieldbook.tracker.offbeat.traits.formats.Formats import com.fieldbook.tracker.offbeat.traits.formats.TraitFormat +import com.fieldbook.tracker.offbeat.traits.formats.parameters.CameraParameters import com.fieldbook.tracker.offbeat.traits.formats.parameters.DetailsParameter import com.fieldbook.tracker.offbeat.traits.formats.parameters.NameParameter -class PhotoFormat : TraitFormat( - format = Formats.CAMERA, - defaultLayoutId = R.layout.trait_photo, - layoutView = null, - nameStringResourceId = R.string.traits_format_photo, - iconDrawableResourceId = R.drawable.ic_trait_camera, - stringNameAux = null, +open class PhotoFormat( + override var format: Formats = Formats.CAMERA, + override var defaultLayoutId: Int = R.layout.trait_photo, + override var layoutView: View? = null, + override var nameStringResourceId: Int = R.string.traits_format_photo, + override var iconDrawableResourceId: Int = R.drawable.ic_trait_camera, + override var stringNameAux: ((Context) -> String?)? = null +) : TraitFormat( + format = format, + defaultLayoutId = defaultLayoutId, + layoutView = layoutView, + nameStringResourceId = nameStringResourceId, + iconDrawableResourceId = iconDrawableResourceId, + stringNameAux = stringNameAux, NameParameter(), - DetailsParameter() + DetailsParameter(), + CameraParameters() ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt index ab7238c8d..9cf2d8568 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt @@ -2,17 +2,12 @@ package com.fieldbook.tracker.offbeat.traits.formats.contracts import com.fieldbook.tracker.R import com.fieldbook.tracker.offbeat.traits.formats.Formats -import com.fieldbook.tracker.offbeat.traits.formats.TraitFormat -import com.fieldbook.tracker.offbeat.traits.formats.parameters.DetailsParameter -import com.fieldbook.tracker.offbeat.traits.formats.parameters.NameParameter -class UsbCameraFormat : TraitFormat( +class UsbCameraFormat : PhotoFormat( format = Formats.USB_CAMERA, defaultLayoutId = R.layout.trait_usb_camera, layoutView = null, nameStringResourceId = R.string.traits_format_usb_camera, iconDrawableResourceId = R.drawable.ic_trait_usb, - stringNameAux = null, - NameParameter(), - DetailsParameter() + stringNameAux = null ) \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraParameters.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraParameters.kt new file mode 100644 index 000000000..c15d682ca --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraParameters.kt @@ -0,0 +1,102 @@ +package com.fieldbook.tracker.offbeat.traits.formats.parameters + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.RadioButton +import android.widget.RadioGroup +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.get +import com.fieldbook.tracker.R +import com.fieldbook.tracker.database.DataHelper +import com.fieldbook.tracker.objects.TraitObject +import com.fieldbook.tracker.offbeat.traits.formats.ValidationResult + +class CameraParameters : BaseFormatParameter( + nameStringResourceId = R.string.traits_create_camera, + defaultLayoutId = R.layout.list_item_trait_parameter_camera, + parameter = Parameters.CAMERA +) { + + override fun createViewHolder( + parent: ViewGroup, + ): BaseFormatParameter.ViewHolder { + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.list_item_trait_parameter_camera, parent, false) + return ViewHolder(v) + } + + class ViewHolder(itemView: View) : BaseFormatParameter.ViewHolder(itemView) { + + private val radioGroup: RadioGroup = + itemView.findViewById(R.id.list_item_trait_parameter_camera_rg) + + init { + + //programmatically add radio button from camera types + CameraTypes.entries.forEach { + + val radioButton = RadioButton(itemView.context) + + radioButton.id = it.ordinal + + radioButton.text = it.format.getName(itemView.context) + + radioButton.setCompoundDrawablesWithIntrinsicBounds( + AppCompatResources.getDrawable(itemView.context, it.format.getIcon()), + null, + null, + null, + ) + + if (it == CameraTypes.DEFAULT) { + + radioButton.isChecked = true + + } + + radioGroup.addView(radioButton) + } + } + + override fun merge(traitObject: TraitObject) = traitObject.apply { + + CameraTypes.entries.forEach { cameraType -> + + val radioButton = radioGroup[cameraType.ordinal] as RadioButton + + if (radioButton.isChecked) { + + val name = cameraType.format.getDatabaseName(itemView.context) + + format = name + + } + } + } + + override fun load(traitObject: TraitObject?): Boolean { + try { + traitObject?.format?.let { format -> + + CameraTypes.entries.forEach { cameraType -> + + if (cameraType.format.getDatabaseName(itemView.context) == format) { + + (radioGroup[cameraType.ordinal] as RadioButton).isChecked = true + } + } + } + } catch (e: Exception) { + e.printStackTrace() + return false + } + return true + } + + override fun validate( + database: DataHelper, + initialTraitObject: TraitObject? + ) = ValidationResult() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraTypes.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraTypes.kt new file mode 100644 index 000000000..e061aed93 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/CameraTypes.kt @@ -0,0 +1,7 @@ +package com.fieldbook.tracker.offbeat.traits.formats.parameters + +import com.fieldbook.tracker.offbeat.traits.formats.Formats + +enum class CameraTypes(var format: Formats) { + DEFAULT(Formats.CAMERA), USB(Formats.USB_CAMERA), GO_PRO(Formats.GO_PRO), CANON(Formats.CANON) +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/Parameters.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/Parameters.kt index 68e914aae..e076825bc 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/Parameters.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/parameters/Parameters.kt @@ -2,6 +2,6 @@ package com.fieldbook.tracker.offbeat.traits.formats.parameters enum class Parameters { - FORMAT, NAME, DEFAULT_VALUE, DETAILS, MAXIMUM, MINIMUM, CATEGORIES; + FORMAT, NAME, DEFAULT_VALUE, DETAILS, MAXIMUM, MINIMUM, CATEGORIES, CAMERA; } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/preferences/CanonPreferencesFragment.kt b/app/src/main/java/com/fieldbook/tracker/preferences/CanonPreferencesFragment.kt new file mode 100644 index 000000000..cf8a4cb29 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/preferences/CanonPreferencesFragment.kt @@ -0,0 +1,37 @@ +package com.fieldbook.tracker.preferences + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.PreferencesActivity + +class CanonPreferencesFragment : PreferenceFragmentCompat() { + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + + setPreferencesFromResource(R.xml.preferences_canon, rootKey) + + (this.activity as PreferencesActivity?)?.supportActionBar?.title = + getString(R.string.preferences_canon_title) + + + val helpPreference = findPreference(GeneralKeys.CANON_HELP) + + helpPreference?.setOnPreferenceClickListener { + + launchCanonManual() + + true + } + } + + private fun launchCanonManual() { + + startActivity(Intent(Intent.ACTION_VIEW).also { + it.data = Uri.parse("https://docs.fieldbook.phenoapps.org/en/latest/") + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java index 7dcf3532b..b776329d9 100644 --- a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java +++ b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java @@ -112,6 +112,16 @@ public class GeneralKeys { public static final String GNSS_PRECISION_OK_SOUND = "GNSS_PRECISION_OK_SOUND"; + // Canon + public static final String CANON_SSID_NAME = "com.tracker.fieldbook.preferences.keys.canon.ssid_name"; + + public static final String CANON_IP = "com.tracker.fieldbook.preferences.keys.canon.ip"; + + public static final String CANON_PORT = "com.tracker.fieldbook.preferences.keys.canon.port"; + + public static final String CANON_DEBUG = "com.tracker.fieldbook.preferences.keys.canon.debug"; + public static final String CANON_HELP = "com.tracker.fieldbook.preferences.keys.canon.help"; + //Beta feature keys public static final String REPEATED_VALUES_PREFERENCE_KEY = "com.tracker.fieldbook.preferences.keys.repeated_values"; public static final String MLKIT_PREFERENCE_KEY = "com.tracker.fieldbook.preferences.keys.mlkit"; @@ -217,10 +227,6 @@ public class GeneralKeys { @NotNull public static final Object SORT_ORDER = "com.fieldbook.tracker.field_sort_order"; - //Canon - public static final String CANON_IP = "CANON_IP"; - public static final String CANON_PORT = "CANON_PORT"; - private GeneralKeys() { } diff --git a/app/src/main/java/com/fieldbook/tracker/preferences/PreferencesFragment.java b/app/src/main/java/com/fieldbook/tracker/preferences/PreferencesFragment.java index 43f63793f..134039123 100644 --- a/app/src/main/java/com/fieldbook/tracker/preferences/PreferencesFragment.java +++ b/app/src/main/java/com/fieldbook/tracker/preferences/PreferencesFragment.java @@ -40,6 +40,8 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { config.index(R.xml.preferences_profile); config.index(R.xml.preferences_sounds); config.index(R.xml.preferences_experimental); + config.index(R.xml.preferences_canon); + config.index(R.xml.preferences_geonav); ((PreferencesActivity) this.getActivity()).getSupportActionBar().setTitle(getString(R.string.settings_advanced)); } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt new file mode 100644 index 000000000..4d7bcdd2f --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt @@ -0,0 +1,351 @@ +package com.fieldbook.tracker.traits + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Point +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.DocumentsContract +import android.util.AttributeSet +import android.util.Log +import android.widget.ImageView +import android.widget.Toast +import androidx.documentfile.provider.DocumentFile +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.CollectActivity +import com.fieldbook.tracker.database.models.ObservationModel +import com.fieldbook.tracker.objects.RangeObject +import com.fieldbook.tracker.preferences.GeneralKeys +import com.fieldbook.tracker.utilities.DocumentTreeUtil +import com.fieldbook.tracker.utilities.ExifUtil +import com.fieldbook.tracker.utilities.FileUtil +import com.google.android.material.floatingactionbutton.FloatingActionButton +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.phenoapps.adapters.ImageAdapter +import org.phenoapps.androidlibrary.Utils +import java.io.FileNotFoundException +import javax.inject.Inject + +@AndroidEntryPoint +abstract class CameraTrait : + BaseTraitLayout, + ImageAdapter.ImageItemHandler { + + @Inject + lateinit var preferences: SharedPreferences + + companion object { + const val TAG = "Camera" + const val type = "canon" + } + + protected var activity: Activity? = null + protected var connectBtn: FloatingActionButton? = null + protected var captureBtn: FloatingActionButton? = null + protected var imageView: ImageView? = null + protected var recyclerView: RecyclerView? = null + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + override fun layoutId(): Int { + return R.layout.trait_canon + } + + override fun setNaTraitsText() {} + override fun type(): String { + return type + } + + override fun loadLayout() { + + //slight delay to make navigation a bit faster + Handler(Looper.getMainLooper()).postDelayed({ + + loadAdapterItems() + + }, 500) + + super.loadLayout() + } + + override fun init(act: Activity) { + + connectBtn = act.findViewById(R.id.canon_fragment_connect_btn) + captureBtn = act.findViewById(R.id.canon_fragment_capture_btn) + imageView = act.findViewById(R.id.trait_canon_iv) + recyclerView = act.findViewById(R.id.canon_fragment_rv) + + recyclerView?.adapter = ImageAdapter(this) + + activity = act + + } + + val background = CoroutineScope(Dispatchers.IO) + protected fun saveBitmapToStorage(bmp: Bitmap, obsUnit: RangeObject) { + + val plot = obsUnit.plot_id + val studyId = collectActivity.studyId + val time = Utils.getDateTime() + val person = (activity as? CollectActivity)?.person + val location = (activity as? CollectActivity)?.locationByPreferences + + background.launch { + + //get current trait's trait name, use it as a plot_media directory + currentTrait.name?.let { traitName -> + + val sanitizedTraitName = FileUtil.sanitizeFileName(traitName) + + val traitDbId = currentTrait.id + + //get the bitmap from the texture view, only use it if its not null + + DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName)?.let { dir -> + + val name = "${sanitizedTraitName}_${plot}_$time.png" + + dir.createFile("*/*", name)?.let { file -> + + context.contentResolver.openOutputStream(file.uri)?.let { output -> + + bmp.compress(Bitmap.CompressFormat.PNG, 100, output) + + database.insertObservation( + plot, traitDbId, type, file.uri.toString(), + person, + location, "", studyId, + null, + null, + null + ) + + //if sdk > 24, can write exif information to the image + //goal is to encode observation variable model into the user comments + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + + ExifUtil.saveJsonToExif( + context, + currentTrait, + file.uri + ) + } + } + } + + loadAdapterItems() + + } + } + } + } + + override fun deleteTraitListener() { + + if (!isLocked) { + + (recyclerView?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition() + ?.let { index -> + + if (index > -1) { + + (recyclerView?.adapter as? ImageAdapter)?.currentList?.get(index) + ?.let { model -> + + showDeleteImageDialog(model) + + } + } + } + } + } + + private fun showDeleteImageDialog(model: ImageAdapter.Model) { + + if (!isLocked) { + context.contentResolver.openInputStream(Uri.parse(model.uri)).use { input -> + + val imageView = ImageView(context) + imageView.setImageBitmap(BitmapFactory.decodeStream(input)) + + AlertDialog.Builder(context, R.style.AppAlertDialog) + .setTitle(R.string.delete_local_photo) + .setOnCancelListener { dialog -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { dialog, _ -> + + dialog.dismiss() + + deleteItem(model) + + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setView(imageView) + .show() + } + } + } + + private fun scrollToLast() { + + try { + + recyclerView?.postDelayed({ + + val pos = recyclerView?.adapter?.itemCount ?: 1 + + recyclerView?.scrollToPosition(pos - 1) + + }, 500L) + + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + + private fun loadAdapterItems() { + + val thumbnailModels = getImageObservations().mapNotNull { + + var model: ImageAdapter.Model? = null + + try { + + DocumentsContract.getDocumentThumbnail( + context.contentResolver, + Uri.parse(it.value), Point(256, 256), null + )?.let { bmp -> + + model = ImageAdapter.Model(it.value, bmp) + + } + + } catch (f: FileNotFoundException) { + + f.printStackTrace() + + model = null + } + + model + } + + activity?.runOnUiThread { + + (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) + + } + } + + private fun getImageObservations(): Array { + + val traitDbId = collectActivity.traitDbId.toInt() + val plot = collectActivity.observationUnit + val studyId = collectActivity.studyId + + return database.getAllObservations(studyId).filter { + it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot + }.toTypedArray() + } + + private fun deleteItem(model: ImageAdapter.Model) { + + val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() + + val plot = currentRange.plot_id + + val traitDbId = currentTrait.id + + getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> + + try { + + DocumentFile.fromSingleUri(context, Uri.parse(observation.value)) + ?.let { image -> + + val result = image.delete() + + if (result) { + + database.deleteTraitByValue( + studyId, + plot, + traitDbId, + image.uri.toString() + ) + + //val index = (recyclerView?.adapter as ImageAdapter).currentList.indexOf(model) + + //recyclerView?.adapter as ImageAdapter).notifyItemRemoved(index) + + loadAdapterItems() + + } else { + + collectActivity.runOnUiThread { + + Toast.makeText( + context, + R.string.photo_failed_to_delete, + Toast.LENGTH_SHORT + ).show() + + } + } + } + + } catch (e: Exception) { + + Log.e(UsbCameraTraitLayout.TAG, "Failed to delete images.", e) + + } + } + } + + override fun onItemClicked(model: ImageAdapter.Model) { + + if (!isLocked) { + + getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> + + DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> + + activity?.startActivity(Intent(Intent.ACTION_VIEW, image.uri).also { + it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }) + } + } + } + } + + override fun onItemDeleted(model: ImageAdapter.Model) { + + showDeleteImageDialog(model) + } + + override fun refreshLock() { + super.refreshLock() + (context as CollectActivity).traitLockData() + } +} + diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt index 5484eb6ef..af3411e44 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt @@ -1,70 +1,37 @@ package com.fieldbook.tracker.traits -import android.app.Activity -import android.app.AlertDialog import android.content.Context -import android.content.Intent -import android.content.SharedPreferences import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Point -import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper -import android.provider.DocumentsContract import android.util.AttributeSet -import android.util.Log import android.view.View -import android.widget.ImageView -import android.widget.ProgressBar -import android.widget.Toast -import androidx.documentfile.provider.DocumentFile -import androidx.preference.PreferenceManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.fieldbook.tracker.R -import com.fieldbook.tracker.activities.CollectActivity -import com.fieldbook.tracker.canon.CameraControlFactory -import com.fieldbook.tracker.canon.Controller -import com.fieldbook.tracker.database.models.ObservationModel -import com.fieldbook.tracker.dialogs.CanonConnectDialog +import com.fieldbook.tracker.devices.ptpip.PtpSessionCallback +import com.fieldbook.tracker.objects.RangeObject import com.fieldbook.tracker.preferences.GeneralKeys -import com.fieldbook.tracker.utilities.DocumentTreeUtil -import com.fieldbook.tracker.utilities.ExifUtil -import com.fieldbook.tracker.utilities.FileUtil -import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.fieldbook.tracker.utilities.WifiHelper import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.phenoapps.adapters.ImageAdapter -import org.phenoapps.androidlibrary.Utils -import java.io.FileNotFoundException -import javax.inject.Inject @AndroidEntryPoint -class CanonTrait : BaseTraitLayout, ImageAdapter.ImageItemHandler { +class CanonTrait : + CameraTrait, + ImageAdapter.ImageItemHandler, + PtpSessionCallback, + WifiHelper.WifiRequester { - @Inject - lateinit var preferences: SharedPreferences - - private val canonController by lazy { - - CameraControlFactory(PreferenceManager.getDefaultSharedPreferences(context)).create()?.let { api -> - - Controller(api) - } - } + private val uiScope = CoroutineScope(Dispatchers.Main) companion object { const val TAG = "Canon" const val type = "canon" + const val CAMERA_SHUTTER_DELAY_MS = 2000L } - private var activity: Activity? = null - private var connectBtn: FloatingActionButton? = null - private var captureBtn: FloatingActionButton? = null - private var imageView: ImageView? = null - private var recyclerView: RecyclerView? = null - constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( @@ -73,27 +40,6 @@ class CanonTrait : BaseTraitLayout, ImageAdapter.ImageItemHandler { defStyleAttr ) - override fun layoutId(): Int { - return R.layout.trait_canon - } - - override fun setNaTraitsText() {} - override fun type(): String { - return type - } - - override fun init(act: Activity) { - - connectBtn = act.findViewById(R.id.canon_fragment_connect_btn) - captureBtn = act.findViewById(R.id.canon_fragment_capture_btn) - imageView = act.findViewById(R.id.trait_canon_iv) - recyclerView = act.findViewById(R.id.canon_fragment_rv) - - recyclerView?.adapter = ImageAdapter(this) - - activity = act - } - private fun setup() { activity?.runOnUiThread { @@ -104,142 +50,26 @@ class CanonTrait : BaseTraitLayout, ImageAdapter.ImageItemHandler { connectBtn?.visibility = View.VISIBLE - connectBtn?.setOnClickListener { - - CanonConnectDialog(activity!!) { - - //startConnection() - - }.show((activity as CollectActivity).supportFragmentManager, "Canon") - } - } - - canonController?.awaitConnection { - - startConnection() - - } - } - - private fun startConnection() { - - connectBtn?.visibility = View.INVISIBLE - - waitForCanonApi() - - } - - private val bridge = object : Controller.ControllerBridge { - - override fun onConnected() { - captureBtn?.visibility = View.VISIBLE - } - - override fun onStartCaptureUi() { - startCaptureUi() - } - - override fun onReceiveStreamImage(bmp: Bitmap) { - imageView?.visibility = View.VISIBLE - imageView?.setImageBitmap(bmp) - } - - override fun onFail() { - setup() - } - - override fun saveBitmap(bmp: Bitmap) { - saveBitmapToStorage(bmp) - scrollToLast() - } - } - - /** - * 1. check for device connection NUMBER_OF_CONNECTION_ATTEMPTS times - * 2. turn movie mode to off (starts still picture capture) - * 3. turn on live view (turns on lcd) - * 4. - */ - private fun waitForCanonApi() { - - canonController?.establishStream(bridge) - - } - - private fun startCaptureUi() { + if (controller.getCanonApi().isConnected) { - captureBtn?.visibility = View.VISIBLE + startCanonSession() - captureBtn?.setOnClickListener { + } else controller.getWifiHelper().startWifiSearch(this) - canonController?.postCameraShutter(bridge) - - } - } - - private fun saveBitmapToStorage(bmp: Bitmap) { - - //get current trait's trait name, use it as a plot_media directory - currentTrait.name?.let { traitName -> - - val sanitizedTraitName = FileUtil.sanitizeFileName(traitName) - - val traitDbId = currentTrait.id - - //get the bitmap from the texture view, only use it if its not null - - DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName)?.let { dir -> - - val plot = collectActivity.observationUnit - val studyId = collectActivity.studyId - val time = Utils.getDateTime() - val name = "${sanitizedTraitName}_${plot}_$time.png" - - dir.createFile("*/*", name)?.let { file -> - - context.contentResolver.openOutputStream(file.uri)?.let { output -> - - bmp.compress(Bitmap.CompressFormat.PNG, 100, output) - - database.insertObservation( - plot, traitDbId, type, file.uri.toString(), - (activity as? CollectActivity)?.person, - (activity as? CollectActivity)?.locationByPreferences, "", studyId, - null, - null, - null + connectBtn?.setOnClickListener { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + collectActivity.advisor().withPermission( + arrayOf( + android.Manifest.permission.ACCESS_COARSE_LOCATION, + android.Manifest.permission.ACCESS_FINE_LOCATION, + android.Manifest.permission.NEARBY_WIFI_DEVICES, + android.Manifest.permission.BLUETOOTH_SCAN ) + ) { - //if sdk > 24, can write exif information to the image - //goal is to encode observation variable model into the user comments - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - - ExifUtil.saveJsonToExif( - context, - currentTrait, - file.uri - ) - } - } - } - - loadAdapterItems() - - } - } - } - - override fun deleteTraitListener() { - - if (!isLocked) { - - (recyclerView?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition()?.let { index -> + controller.getWifiHelper().disconnect() - if (index > -1) { - - (recyclerView?.adapter as? ImageAdapter)?.currentList?.get(index)?.let { model -> - - showDeleteImageDialog(model) + controller.getWifiHelper().startWifiSearch(this) } } @@ -247,176 +77,95 @@ class CanonTrait : BaseTraitLayout, ImageAdapter.ImageItemHandler { } } - private fun showDeleteImageDialog(model: ImageAdapter.Model) { - - if (!isLocked) { - context.contentResolver.openInputStream(Uri.parse(model.uri)).use { input -> - - val imageView = ImageView(context) - imageView.setImageBitmap(BitmapFactory.decodeStream(input)) - - AlertDialog.Builder(context, R.style.AppAlertDialog) - .setTitle(R.string.delete_local_photo) - .setOnCancelListener { dialog -> dialog.dismiss() } - .setPositiveButton(android.R.string.ok) { dialog, _ -> - - dialog.dismiss() - - deleteItem(model) - - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } - .setView(imageView) - .show() - } - } - } - - private fun scrollToLast() { + private fun startCanonSession() { - try { + val scope = CoroutineScope(Dispatchers.IO) - recyclerView?.postDelayed({ + scope.launch(Dispatchers.IO) { - val pos = recyclerView?.adapter?.itemCount ?: 1 - - recyclerView?.scrollToPosition(pos - 1) - - }, 500L) + try { + controller.getCanonApi().initiateSession(this@CanonTrait) - } catch (e: Exception) { + } catch (e: Exception) { - e.printStackTrace() + controller.getCanonApi().stopSession() + e.printStackTrace() + } } } override fun loadLayout() { - - //slight delay to make navigation a bit faster - Handler(Looper.getMainLooper()).postDelayed({ - - loadAdapterItems() - - }, 500) - - setup() - super.loadLayout() + setup() } - private fun loadAdapterItems() { + override fun onSessionStart() { - activity?.runOnUiThread { + uiScope.launch { - val thumbnailModels = getImageObservations().mapNotNull { + connectBtn?.visibility = View.INVISIBLE - var model: ImageAdapter.Model? = null + imageView?.visibility = View.VISIBLE - try { + captureBtn?.setOnClickListener { - DocumentsContract.getDocumentThumbnail(context.contentResolver, - Uri.parse(it.value), Point(256, 256), null)?.let { bmp -> + captureBtn?.isEnabled = false - model = ImageAdapter.Model(it.value, bmp) + Handler(Looper.getMainLooper()).postDelayed({ - } + captureBtn?.isEnabled = true - } catch (f: FileNotFoundException) { + }, CAMERA_SHUTTER_DELAY_MS) - f.printStackTrace() - - model = null - } + controller.getCanonApi().startSingleShotCapture(currentRange) - model } - - (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) } } - private fun getImageObservations(): Array { - - val traitDbId = collectActivity.traitDbId.toInt() - val plot = collectActivity.observationUnit - val studyId = collectActivity.studyId - - return database.getAllObservations(studyId).filter { - it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot - }.toTypedArray() - } - - private fun deleteItem(model: ImageAdapter.Model) { - - val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() - - val plot = currentRange.plot_id - - val traitDbId = currentTrait.id - - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> - - try { - - DocumentFile.fromSingleUri(context, Uri.parse(observation.value)) - ?.let { image -> + override fun onSessionStop() { - val result = image.delete() + uiScope.launch { - if (result) { - - database.deleteTraitByValue( - studyId, - plot, - traitDbId, - image.uri.toString() - ) + imageView?.visibility = View.INVISIBLE - loadAdapterItems() + captureBtn?.visibility = View.INVISIBLE - } else { + connectBtn?.visibility = View.VISIBLE - collectActivity.runOnUiThread { + captureBtn?.setOnClickListener(null) + } + } - Toast.makeText(context, R.string.photo_failed_to_delete, Toast.LENGTH_SHORT).show() + override fun onPreview(bmp: Bitmap) { - } - } - } + uiScope.launch(Dispatchers.Main) { - } catch (e: Exception) { + captureBtn?.visibility = View.VISIBLE - Log.e(UsbCameraTraitLayout.TAG, "Failed to delete images.", e) + imageView?.setImageBitmap(bmp) - } } } - override fun onItemClicked(model: ImageAdapter.Model) { + override fun onBitmapCaptured(bmp: Bitmap, obsUnit: RangeObject) { - if (!isLocked) { + uiScope.launch(Dispatchers.Main) { - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> + saveBitmapToStorage(bmp, obsUnit) - DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> - - activity?.startActivity(Intent(Intent.ACTION_VIEW, image.uri).also { - it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - }) - } - } } } - override fun onItemDeleted(model: ImageAdapter.Model) { + override fun getSsidName() = + preferences.getString(GeneralKeys.CANON_SSID_NAME, "Canon") ?: "Canon" - showDeleteImageDialog(model) - } + override fun onNetworkBound() { + + startCanonSession() - override fun refreshLock() { - super.refreshLock() - (context as CollectActivity).traitLockData() } -} \ No newline at end of file +} + diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt new file mode 100644 index 000000000..ad58b32ea --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt @@ -0,0 +1,158 @@ +package com.fieldbook.tracker.utilities + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiManager +import android.net.wifi.WifiNetworkSpecifier +import android.os.Build +import android.os.Handler +import android.os.Looper +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class WifiHelper @Inject constructor(@ApplicationContext private val context: Context) { + + interface WifiRequester { + fun getSsidName(): String + fun onNetworkBound() + } + + private var requester: WifiRequester? = null + + private val wifiManager by lazy { + + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + + } + + fun disconnect() { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + connectivityManager.bindProcessToNetwork(null) + } + } + + fun startWifiSearch(requester: WifiRequester) { + + this.requester = requester + + val intentFilter = IntentFilter() + intentFilter.addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) + intentFilter.addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION) + + try { + context.unregisterReceiver(wifiReceiver) + } catch (_: Exception) { } + + context.registerReceiver(wifiReceiver, intentFilter) + + val success = wifiManager.startScan() + if (!success) { + // scan failure handling + scanFailure() + } + } + + fun stopWifiSearch() { + + try { + + context.unregisterReceiver(wifiReceiver) + + } catch (_: Exception) {} + } + + private fun scanSuccess() { + + val results = wifiManager.scanResults + + results.forEach { result -> + + val name = this.requester?.getSsidName() ?: "" + + if (name in result.SSID) { + + startWifiSpecifier(result.SSID?.toString(), result.BSSID?.toString()) + } + } + } + + private fun scanFailure() { + + val results = wifiManager.scanResults + + } + + private fun startWifiSpecifier(ssid: String?, bssid: String?) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + + val specifier = + WifiNetworkSpecifier.Builder() + .setSsid(ssid!!) + //adding BSSID will remove the need for the "connect" dialog + //.setBssid(MacAddress.fromString(bssid!!)) + .build() + + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(specifier) + .build() + + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val networkCallback = object : ConnectivityManager.NetworkCallback() { + + override fun onAvailable(network: Network) { + super.onAvailable(network) + + val bound = connectivityManager.bindProcessToNetwork(network) + + requester?.onNetworkBound() + + stopWifiSearch() + } + } + + connectivityManager.requestNetwork( + request, + networkCallback, + Handler(Looper.getMainLooper()), + 10000 + ) + } + } + + private val wifiReceiver = object : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + + val success = intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, false) + + if (success) { + + scanSuccess() + + } else { + + scanFailure() + + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/list_item_trait_parameter_camera.xml b/app/src/main/res/layout/list_item_trait_parameter_camera.xml new file mode 100644 index 000000000..0e79f9bda --- /dev/null +++ b/app/src/main/res/layout/list_item_trait_parameter_camera.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/trait_canon.xml b/app/src/main/res/layout/trait_canon.xml index 9c664aa65..77012784a 100644 --- a/app/src/main/res/layout/trait_canon.xml +++ b/app/src/main/res/layout/trait_canon.xml @@ -17,7 +17,10 @@ - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 294faab2e..01ebbfc89 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -215,6 +215,7 @@ Details Optional Categories + Camera Maximum number of photos True False @@ -1032,9 +1033,20 @@ Subtracts one day from the current date Adds one day to the current date Something went wrong showing the min/max of this trait format + + shutter button Canon Canon IP Address Canon Port + Canon + This name is used to find your device\'s AP. This name must be contained within the actual SSID. + SSID Name + This is the ip displayed on your camera when setting up the AP. + This is the port displayed on your camera when setting up the AP. + Help + This will open a URL to the FieldBook Canon manual. + Debug + A preferences for developers to monitor device output. \ No newline at end of file diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 8b01d1d1d..6ae60e566 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -36,6 +36,12 @@ android:title="@string/preferences_geonav_title" app:fragment="com.fieldbook.tracker.preferences.GeoNavPreferencesFragment" /> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_experimental.xml b/app/src/main/res/xml/preferences_experimental.xml index bad4fdc90..9f686ad6f 100644 --- a/app/src/main/res/xml/preferences_experimental.xml +++ b/app/src/main/res/xml/preferences_experimental.xml @@ -41,18 +41,6 @@ android:title="@string/preferences_experimental_alpha_title" app:iconSpaceReserved="false"> - - - - \ No newline at end of file diff --git a/app/src/test/java/BrapiServiceTest.java b/app/src/test/java/BrapiServiceTest.java index 8dc59fcf9..f1d78f1bf 100644 --- a/app/src/test/java/BrapiServiceTest.java +++ b/app/src/test/java/BrapiServiceTest.java @@ -649,7 +649,7 @@ public void checkPostImageMetaData() { final FieldBookImage[] postImageMetaDataResponse = {null}; final CountDownLatch signal = new CountDownLatch(1); - FieldBookImage image = new FieldBookImage(context, "/path/test.jpg", Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); + FieldBookImage image = new FieldBookImage(context, "/path/test.jpg", "traitName", Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)); image.setUnitDbId(ouDbId); image.setTimestamp(OffsetDateTime.now()); From d463bc6001221847a6bf7d44ccaec85d59cbd2e0 Mon Sep 17 00:00:00 2001 From: chaneylc Date: Fri, 1 Mar 2024 11:22:41 -0600 Subject: [PATCH 4/6] added usb camera improvements --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + .../tracker/activities/CollectActivity.java | 34 +- .../tracker/devices/camera/CameraInterface.kt | 27 - .../tracker/devices/camera/CanonApi.kt | 1 + .../tracker/devices/camera/UsbCameraApi.kt | 132 ++++ .../tracker/interfaces/CollectController.kt | 4 + .../traits/formats/contracts/CanonFormat.kt | 6 +- .../formats/contracts/UsbCameraFormat.kt | 2 +- .../tracker/preferences/GeneralKeys.java | 3 + .../tracker/traits/AbstractCameraTrait.kt | 353 ++++++++++ .../fieldbook/tracker/traits/CameraTrait.kt | 335 +-------- .../fieldbook/tracker/traits/CanonTrait.kt | 20 +- .../tracker/traits/UsbCameraTraitLayout.kt | 645 +++--------------- app/src/main/res/layout/activity_collect.xml | 8 + .../res/layout/fragment_storage_definer.xml | 1 + .../{trait_canon.xml => trait_camera.xml} | 32 +- app/src/main/res/values/strings.xml | 5 + 18 files changed, 670 insertions(+), 941 deletions(-) delete mode 100644 app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/camera/UsbCameraApi.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt rename app/src/main/res/layout/{trait_canon.xml => trait_camera.xml} (74%) diff --git a/app/build.gradle b/app/build.gradle index f3a08709c..ea898e73e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -147,7 +147,7 @@ dependencies { implementation 'com.google.zxing:core:3.4.1' - implementation('com.github.phenoapps:phenolib:v0.9.50') + implementation('com.github.phenoapps:phenolib:v0.9.51-SNAPSHOT') implementation 'com.google.android.exoplayer:exoplayer:2.19.1' implementation 'com.arthenica:ffmpeg-kit-min:5.1.LTS' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9cd30d8af..a5c91d187 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -222,6 +222,7 @@ android:name="android.hardware.camera" android:required="false" /> + diff --git a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java index bd01f9456..07b207d95 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java @@ -50,6 +50,7 @@ import com.fieldbook.tracker.database.models.ObservationModel; import com.fieldbook.tracker.database.models.ObservationUnitModel; import com.fieldbook.tracker.devices.camera.CanonApi; +import com.fieldbook.tracker.devices.camera.UsbCameraApi; import com.fieldbook.tracker.dialogs.GeoNavCollectDialog; import com.fieldbook.tracker.interfaces.FieldSwitcher; import com.fieldbook.tracker.location.GPSTracker; @@ -67,7 +68,6 @@ import com.fieldbook.tracker.traits.LayoutCollections; import com.fieldbook.tracker.traits.PhotoTraitLayout; import com.fieldbook.tracker.utilities.CategoryJsonUtil; -import com.fieldbook.tracker.utilities.DevicePairer; import com.fieldbook.tracker.utilities.DocumentTreeUtil; import com.fieldbook.tracker.utilities.FieldAudioHelper; import com.fieldbook.tracker.utilities.FieldSwitchImpl; @@ -93,6 +93,7 @@ import com.getkeepsafe.taptargetview.TapTargetSequence; import com.google.zxing.integration.android.IntentIntegrator; import com.google.zxing.integration.android.IntentResult; +import com.serenegiant.widget.UVCCameraTextureView; import org.brapi.v2.model.pheno.BrAPIScaleValidValuesCategories; import org.phenoapps.interfaces.security.SecureBluetooth; @@ -145,6 +146,9 @@ public class CollectActivity extends ThemedActivity private GeoNavHelper geoNavHelper; + @Inject + UsbCameraApi usbCameraApi; + @Inject CanonApi canonApi; @@ -193,6 +197,8 @@ public class CollectActivity extends ThemedActivity public static String TAG = "Field Book"; public static String GEOTAG = "GeoNav"; + UVCCameraTextureView uvcView; + ImageButton deleteValue; ImageButton missingValue; ImageButton barcodeInput; @@ -329,6 +335,18 @@ public void handleMessage(Message msg) { } + @Override + protected void onStart() { + super.onStart(); + usbCameraApi.onStart(); + } + + @Override + protected void onStop() { + super.onStop(); + usbCameraApi.onStop(); + } + public void triggerTts(String text) { if (ep.getBoolean(GeneralKeys.TTS_LANGUAGE_ENABLED, false)) { ttsHelper.speak(text); @@ -513,6 +531,8 @@ private void loadScreen() { }); refreshInfoBarAdapter(); + + uvcView = findViewById(R.id.collect_activity_uvc_tv); } //when softkeyboard is displayed, reset the snackbar to redisplay with a calculated bottom margin @@ -963,6 +983,8 @@ public void onDestroy() { gnssThreadHelper.stop(); + usbCameraApi.onDestroy(); + super.onDestroy(); } @@ -2555,4 +2577,14 @@ public CanonApi getCanonApi() { @NonNull @Override public WifiHelper getWifiHelper() { return wifiHelper; } + + @NonNull + @Override + public UsbCameraApi getUsbApi() { + return usbCameraApi; + } + + @NonNull + @Override + public UVCCameraTextureView getUvcView() { return uvcView; } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt deleted file mode 100644 index 9b1fa147a..000000000 --- a/app/src/main/java/com/fieldbook/tracker/devices/camera/CameraInterface.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.fieldbook.tracker.devices.camera - -import android.graphics.Bitmap - -sealed interface Device { - fun connect() - fun disconnect() -} - -interface CameraInterface: Device { - - fun startSingleShotCapture() - - fun onPreview(bmp: Bitmap) - - fun onBitmapCaptured(bmp: Bitmap) - -} - -//object DeviceAdapterFactory { -// -// fun create(): Device { -// -// -// } -// -//} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt index 0da115ded..3c8074032 100644 --- a/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt +++ b/app/src/main/java/com/fieldbook/tracker/devices/camera/CanonApi.kt @@ -1,6 +1,7 @@ package com.fieldbook.tracker.devices.camera import android.content.Context +import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Log import androidx.preference.PreferenceManager diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/UsbCameraApi.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/UsbCameraApi.kt new file mode 100644 index 000000000..eda244481 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/camera/UsbCameraApi.kt @@ -0,0 +1,132 @@ +package com.fieldbook.tracker.devices.camera + +import android.content.Context +import android.hardware.usb.UsbDevice +import android.util.Log +import android.widget.ImageView +import com.serenegiant.usb.Size +import com.serenegiant.usb.USBMonitor +import com.serenegiant.usb.UVCCamera +import com.serenegiant.widget.CameraViewInterface +import dagger.hilt.android.qualifiers.ActivityContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import okhttp3.internal.toImmutableList +import javax.inject.Inject + + +class UsbCameraApi @Inject constructor(@ActivityContext private val context: Context) { + + interface Callbacks { + fun onConnected(camera: UVCCamera?, sizes: List) + fun onDisconnected() + fun getUsbCameraInterface(): CameraViewInterface? + fun getPreviewView(): ImageView? + } + + companion object { + const val TAG = "UsbCameraApi" + } + + val background = CoroutineScope(Dispatchers.IO) + val ui = CoroutineScope(Dispatchers.Main) + + var camera: UVCCamera? = null + + private var callbacks: Callbacks? = null + private var monitor: USBMonitor? = null + + private fun getListener(callbacks: Callbacks?) = object : USBMonitor.OnDeviceConnectListener { + + override fun onAttach(device: UsbDevice?) { + + monitor?.requestPermission(device) + + } + + override fun onDetach(device: UsbDevice?) {} + + override fun onConnect( + device: UsbDevice?, + ctrlBlock: USBMonitor.UsbControlBlock?, + createNew: Boolean + ) { + + onConnectCamera(callbacks, ctrlBlock) + + } + + override fun onDisconnect( + device: UsbDevice?, + ctrlBlock: USBMonitor.UsbControlBlock? + ) { + + camera?.close() + + callbacks?.onDisconnected() + } + + override fun onCancel(device: UsbDevice?) {} + + } + + private fun onConnectCamera(callbacks: Callbacks?, ctrlBlock: USBMonitor.UsbControlBlock?) { + + try { + + camera = UVCCamera() + + camera?.open(ctrlBlock) + + initializePreview(callbacks, camera) + + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun initializePreview(callbacks: Callbacks?, camera: UVCCamera?) { + val supportedSizes = camera?.supportedSizeList?.toImmutableList() + ?.distinctBy { it.width to it.height } ?: listOf() + + callbacks?.onConnected(camera, supportedSizes) + } + + fun attachToTraitLayout(callbacks: Callbacks) { + + this.callbacks = callbacks + + if (camera == null) { + + monitor = USBMonitor(context, getListener(callbacks)) + + monitor?.register() + + } else { + + initializePreview(callbacks, camera) + } + } + + fun onStart() { + if (camera != null) { + monitor?.register() + } + } + + fun onStop() { + if (camera != null) { + monitor?.unregister() + } + } + + fun onDestroy() { + if (camera != null) { + monitor?.destroy() + } + } + + fun isConnected(): Boolean = camera != null + + fun getSizes(): List? = camera?.supportedSizeList +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt b/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt index 06d3dd6e4..af4538d93 100644 --- a/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt +++ b/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt @@ -4,6 +4,7 @@ import android.content.Context import android.location.Location import android.os.Handler import com.fieldbook.tracker.devices.camera.CanonApi +import com.fieldbook.tracker.devices.camera.UsbCameraApi import com.fieldbook.tracker.location.GPSTracker import com.fieldbook.tracker.utilities.GeoNavHelper import com.fieldbook.tracker.utilities.GnssThreadHelper @@ -13,6 +14,7 @@ import com.fieldbook.tracker.utilities.WifiHelper import com.fieldbook.tracker.views.CollectInputView import com.fieldbook.tracker.views.RangeBoxView import com.fieldbook.tracker.views.TraitBoxView +import com.serenegiant.widget.UVCCameraTextureView import org.phenoapps.security.SecureBluetoothActivityImpl interface CollectController: FieldController { @@ -43,4 +45,6 @@ interface CollectController: FieldController { fun logNmeaMessage(nmea: String) fun getCanonApi(): CanonApi fun getWifiHelper(): WifiHelper + fun getUsbApi(): UsbCameraApi + fun getUvcView(): UVCCameraTextureView } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt index 823ef7e66..8597fe8bf 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/CanonFormat.kt @@ -1,15 +1,11 @@ package com.fieldbook.tracker.offbeat.traits.formats.contracts -import android.provider.ContactsContract.Contacts.Photo import com.fieldbook.tracker.R import com.fieldbook.tracker.offbeat.traits.formats.Formats -import com.fieldbook.tracker.offbeat.traits.formats.TraitFormat -import com.fieldbook.tracker.offbeat.traits.formats.parameters.DetailsParameter -import com.fieldbook.tracker.offbeat.traits.formats.parameters.NameParameter class CanonFormat : PhotoFormat( format = Formats.CANON, - defaultLayoutId = R.layout.trait_canon, + defaultLayoutId = R.layout.trait_camera, layoutView = null, nameStringResourceId = R.string.traits_format_canon, iconDrawableResourceId = R.drawable.camera_24px, diff --git a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt index 9cf2d8568..b1df974a6 100644 --- a/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt +++ b/app/src/main/java/com/fieldbook/tracker/offbeat/traits/formats/contracts/UsbCameraFormat.kt @@ -5,7 +5,7 @@ import com.fieldbook.tracker.offbeat.traits.formats.Formats class UsbCameraFormat : PhotoFormat( format = Formats.USB_CAMERA, - defaultLayoutId = R.layout.trait_usb_camera, + defaultLayoutId = R.layout.trait_camera, layoutView = null, nameStringResourceId = R.string.traits_format_usb_camera, iconDrawableResourceId = R.drawable.ic_trait_usb, diff --git a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java index b776329d9..aa1926634 100644 --- a/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java +++ b/app/src/main/java/com/fieldbook/tracker/preferences/GeneralKeys.java @@ -227,6 +227,9 @@ public class GeneralKeys { @NotNull public static final Object SORT_ORDER = "com.fieldbook.tracker.field_sort_order"; + public static final String USB_CAMERA_LAST_PREVIEW_WIDTH = "com.fieldbook.tracker.fieldbook.usb_camera.last_width"; + public static final String USB_CAMERA_LAST_PREVIEW_HEIGHT = "com.fieldbook.tracker.fieldbook.usb_camera.last_height"; + private GeneralKeys() { } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt new file mode 100644 index 000000000..6e7c424aa --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt @@ -0,0 +1,353 @@ +package com.fieldbook.tracker.traits + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Point +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.provider.DocumentsContract +import android.util.AttributeSet +import android.util.Log +import android.widget.ImageView +import android.widget.Toast +import androidx.documentfile.provider.DocumentFile +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.fieldbook.tracker.R +import com.fieldbook.tracker.activities.CollectActivity +import com.fieldbook.tracker.database.models.ObservationModel +import com.fieldbook.tracker.objects.RangeObject +import com.fieldbook.tracker.preferences.GeneralKeys +import com.fieldbook.tracker.utilities.DocumentTreeUtil +import com.fieldbook.tracker.utilities.ExifUtil +import com.fieldbook.tracker.utilities.FileUtil +import com.google.android.material.floatingactionbutton.FloatingActionButton +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.phenoapps.adapters.ImageAdapter +import org.phenoapps.androidlibrary.Utils +import java.io.FileNotFoundException +import javax.inject.Inject + +@AndroidEntryPoint +abstract class AbstractCameraTrait : + BaseTraitLayout, + ImageAdapter.ImageItemHandler { + + @Inject + lateinit var preferences: SharedPreferences + + companion object { + const val TAG = "Camera" + const val type = "canon" + } + + protected var activity: Activity? = null + protected var connectBtn: FloatingActionButton? = null + protected var captureBtn: FloatingActionButton? = null + protected var imageView: ImageView? = null + protected var recyclerView: RecyclerView? = null + + protected val background = CoroutineScope(Dispatchers.IO) + protected val ui = CoroutineScope(Dispatchers.Main) + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) + + override fun layoutId(): Int { + return R.layout.trait_camera + } + + override fun setNaTraitsText() {} + override fun type(): String { + return type + } + + override fun loadLayout() { + + //slight delay to make navigation a bit faster + Handler(Looper.getMainLooper()).postDelayed({ + + loadAdapterItems() + + }, 500) + + super.loadLayout() + } + + override fun init(act: Activity) { + + connectBtn = act.findViewById(R.id.camera_fragment_connect_btn) + captureBtn = act.findViewById(R.id.camera_fragment_capture_btn) + imageView = act.findViewById(R.id.trait_camera_iv) + recyclerView = act.findViewById(R.id.camera_fragment_rv) + + recyclerView?.adapter = ImageAdapter(this) + + activity = act + + } + + protected fun saveBitmapToStorage(bmp: Bitmap, obsUnit: RangeObject) { + + val plot = obsUnit.plot_id + val studyId = collectActivity.studyId + val time = Utils.getDateTime() + val person = (activity as? CollectActivity)?.person + val location = (activity as? CollectActivity)?.locationByPreferences + + background.launch { + + //get current trait's trait name, use it as a plot_media directory + currentTrait.name?.let { traitName -> + + val sanitizedTraitName = FileUtil.sanitizeFileName(traitName) + + val traitDbId = currentTrait.id + + //get the bitmap from the texture view, only use it if its not null + + DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName)?.let { dir -> + + val name = "${sanitizedTraitName}_${plot}_$time.png" + + dir.createFile("*/*", name)?.let { file -> + + context.contentResolver.openOutputStream(file.uri)?.let { output -> + + bmp.compress(Bitmap.CompressFormat.PNG, 100, output) + + database.insertObservation( + plot, traitDbId, type, file.uri.toString(), + person, + location, "", studyId, + null, + null, + null + ) + + //if sdk > 24, can write exif information to the image + //goal is to encode observation variable model into the user comments + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + + ExifUtil.saveJsonToExif( + context, + currentTrait, + file.uri + ) + } + } + } + + loadAdapterItems() + + } + } + } + } + + override fun deleteTraitListener() { + + if (!isLocked) { + + (recyclerView?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition() + ?.let { index -> + + if (index > -1) { + + (recyclerView?.adapter as? ImageAdapter)?.currentList?.get(index) + ?.let { model -> + + showDeleteImageDialog(model) + + } + } + } + } + } + + private fun showDeleteImageDialog(model: ImageAdapter.Model) { + + if (!isLocked) { + context.contentResolver.openInputStream(Uri.parse(model.uri)).use { input -> + + val imageView = ImageView(context) + imageView.setImageBitmap(BitmapFactory.decodeStream(input)) + + AlertDialog.Builder(context, R.style.AppAlertDialog) + .setTitle(R.string.delete_local_photo) + .setOnCancelListener { dialog -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { dialog, _ -> + + dialog.dismiss() + + deleteItem(model) + + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setView(imageView) + .show() + } + } + } + + private fun scrollToLast() { + + try { + + recyclerView?.postDelayed({ + + val pos = recyclerView?.adapter?.itemCount ?: 1 + + recyclerView?.scrollToPosition(pos - 1) + + }, 500L) + + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + + private fun loadAdapterItems() { + + val thumbnailModels = getImageObservations().mapNotNull { + + var model: ImageAdapter.Model? = null + + try { + + DocumentsContract.getDocumentThumbnail( + context.contentResolver, + Uri.parse(it.value), Point(256, 256), null + )?.let { bmp -> + + model = ImageAdapter.Model(it.value, bmp) + + } + + } catch (f: FileNotFoundException) { + + f.printStackTrace() + + model = null + } + + model + } + + activity?.runOnUiThread { + + (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) + + } + } + + private fun getImageObservations(): Array { + + val traitDbId = collectActivity.traitDbId.toInt() + val plot = collectActivity.observationUnit + val studyId = collectActivity.studyId + + return database.getAllObservations(studyId).filter { + it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot + }.toTypedArray() + } + + private fun deleteItem(model: ImageAdapter.Model) { + + val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() + + val plot = currentRange.plot_id + + val traitDbId = currentTrait.id + + getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> + + try { + + DocumentFile.fromSingleUri(context, Uri.parse(observation.value)) + ?.let { image -> + + val result = image.delete() + + if (result) { + + database.deleteTraitByValue( + studyId, + plot, + traitDbId, + image.uri.toString() + ) + + //val index = (recyclerView?.adapter as ImageAdapter).currentList.indexOf(model) + + //recyclerView?.adapter as ImageAdapter).notifyItemRemoved(index) + + loadAdapterItems() + + } else { + + collectActivity.runOnUiThread { + + Toast.makeText( + context, + R.string.photo_failed_to_delete, + Toast.LENGTH_SHORT + ).show() + + } + } + } + + } catch (e: Exception) { + + Log.e(UsbCameraTraitLayout.TAG, "Failed to delete images.", e) + + } + } + } + + override fun onItemClicked(model: ImageAdapter.Model) { + + if (!isLocked) { + + getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> + + DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> + + activity?.startActivity(Intent(Intent.ACTION_VIEW, image.uri).also { + it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + }) + } + } + } + } + + override fun onItemDeleted(model: ImageAdapter.Model) { + + showDeleteImageDialog(model) + } + + override fun refreshLock() { + super.refreshLock() + (context as CollectActivity).traitLockData() + } +} + diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt index 4d7bcdd2f..85012cf81 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt @@ -1,61 +1,11 @@ package com.fieldbook.tracker.traits -import android.app.Activity -import android.app.AlertDialog import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Point -import android.net.Uri -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.provider.DocumentsContract import android.util.AttributeSet -import android.util.Log -import android.widget.ImageView -import android.widget.Toast -import androidx.documentfile.provider.DocumentFile -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.fieldbook.tracker.R -import com.fieldbook.tracker.activities.CollectActivity -import com.fieldbook.tracker.database.models.ObservationModel -import com.fieldbook.tracker.objects.RangeObject -import com.fieldbook.tracker.preferences.GeneralKeys -import com.fieldbook.tracker.utilities.DocumentTreeUtil -import com.fieldbook.tracker.utilities.ExifUtil -import com.fieldbook.tracker.utilities.FileUtil -import com.google.android.material.floatingactionbutton.FloatingActionButton import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.phenoapps.adapters.ImageAdapter -import org.phenoapps.androidlibrary.Utils -import java.io.FileNotFoundException -import javax.inject.Inject @AndroidEntryPoint -abstract class CameraTrait : - BaseTraitLayout, - ImageAdapter.ImageItemHandler { - - @Inject - lateinit var preferences: SharedPreferences - - companion object { - const val TAG = "Camera" - const val type = "canon" - } - - protected var activity: Activity? = null - protected var connectBtn: FloatingActionButton? = null - protected var captureBtn: FloatingActionButton? = null - protected var imageView: ImageView? = null - protected var recyclerView: RecyclerView? = null +open class CameraTrait : AbstractCameraTrait { constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) @@ -64,288 +14,5 @@ abstract class CameraTrait : attrs, defStyleAttr ) - - override fun layoutId(): Int { - return R.layout.trait_canon - } - - override fun setNaTraitsText() {} - override fun type(): String { - return type - } - - override fun loadLayout() { - - //slight delay to make navigation a bit faster - Handler(Looper.getMainLooper()).postDelayed({ - - loadAdapterItems() - - }, 500) - - super.loadLayout() - } - - override fun init(act: Activity) { - - connectBtn = act.findViewById(R.id.canon_fragment_connect_btn) - captureBtn = act.findViewById(R.id.canon_fragment_capture_btn) - imageView = act.findViewById(R.id.trait_canon_iv) - recyclerView = act.findViewById(R.id.canon_fragment_rv) - - recyclerView?.adapter = ImageAdapter(this) - - activity = act - - } - - val background = CoroutineScope(Dispatchers.IO) - protected fun saveBitmapToStorage(bmp: Bitmap, obsUnit: RangeObject) { - - val plot = obsUnit.plot_id - val studyId = collectActivity.studyId - val time = Utils.getDateTime() - val person = (activity as? CollectActivity)?.person - val location = (activity as? CollectActivity)?.locationByPreferences - - background.launch { - - //get current trait's trait name, use it as a plot_media directory - currentTrait.name?.let { traitName -> - - val sanitizedTraitName = FileUtil.sanitizeFileName(traitName) - - val traitDbId = currentTrait.id - - //get the bitmap from the texture view, only use it if its not null - - DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName)?.let { dir -> - - val name = "${sanitizedTraitName}_${plot}_$time.png" - - dir.createFile("*/*", name)?.let { file -> - - context.contentResolver.openOutputStream(file.uri)?.let { output -> - - bmp.compress(Bitmap.CompressFormat.PNG, 100, output) - - database.insertObservation( - plot, traitDbId, type, file.uri.toString(), - person, - location, "", studyId, - null, - null, - null - ) - - //if sdk > 24, can write exif information to the image - //goal is to encode observation variable model into the user comments - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - - ExifUtil.saveJsonToExif( - context, - currentTrait, - file.uri - ) - } - } - } - - loadAdapterItems() - - } - } - } - } - - override fun deleteTraitListener() { - - if (!isLocked) { - - (recyclerView?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition() - ?.let { index -> - - if (index > -1) { - - (recyclerView?.adapter as? ImageAdapter)?.currentList?.get(index) - ?.let { model -> - - showDeleteImageDialog(model) - - } - } - } - } - } - - private fun showDeleteImageDialog(model: ImageAdapter.Model) { - - if (!isLocked) { - context.contentResolver.openInputStream(Uri.parse(model.uri)).use { input -> - - val imageView = ImageView(context) - imageView.setImageBitmap(BitmapFactory.decodeStream(input)) - - AlertDialog.Builder(context, R.style.AppAlertDialog) - .setTitle(R.string.delete_local_photo) - .setOnCancelListener { dialog -> dialog.dismiss() } - .setPositiveButton(android.R.string.ok) { dialog, _ -> - - dialog.dismiss() - - deleteItem(model) - - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } - .setView(imageView) - .show() - } - } - } - - private fun scrollToLast() { - - try { - - recyclerView?.postDelayed({ - - val pos = recyclerView?.adapter?.itemCount ?: 1 - - recyclerView?.scrollToPosition(pos - 1) - - }, 500L) - - - } catch (e: Exception) { - - e.printStackTrace() - - } - } - - private fun loadAdapterItems() { - - val thumbnailModels = getImageObservations().mapNotNull { - - var model: ImageAdapter.Model? = null - - try { - - DocumentsContract.getDocumentThumbnail( - context.contentResolver, - Uri.parse(it.value), Point(256, 256), null - )?.let { bmp -> - - model = ImageAdapter.Model(it.value, bmp) - - } - - } catch (f: FileNotFoundException) { - - f.printStackTrace() - - model = null - } - - model - } - - activity?.runOnUiThread { - - (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) - - } - } - - private fun getImageObservations(): Array { - - val traitDbId = collectActivity.traitDbId.toInt() - val plot = collectActivity.observationUnit - val studyId = collectActivity.studyId - - return database.getAllObservations(studyId).filter { - it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot - }.toTypedArray() - } - - private fun deleteItem(model: ImageAdapter.Model) { - - val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() - - val plot = currentRange.plot_id - - val traitDbId = currentTrait.id - - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> - - try { - - DocumentFile.fromSingleUri(context, Uri.parse(observation.value)) - ?.let { image -> - - val result = image.delete() - - if (result) { - - database.deleteTraitByValue( - studyId, - plot, - traitDbId, - image.uri.toString() - ) - - //val index = (recyclerView?.adapter as ImageAdapter).currentList.indexOf(model) - - //recyclerView?.adapter as ImageAdapter).notifyItemRemoved(index) - - loadAdapterItems() - - } else { - - collectActivity.runOnUiThread { - - Toast.makeText( - context, - R.string.photo_failed_to_delete, - Toast.LENGTH_SHORT - ).show() - - } - } - } - - } catch (e: Exception) { - - Log.e(UsbCameraTraitLayout.TAG, "Failed to delete images.", e) - - } - } - } - - override fun onItemClicked(model: ImageAdapter.Model) { - - if (!isLocked) { - - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> - - DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> - - activity?.startActivity(Intent(Intent.ACTION_VIEW, image.uri).also { - it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - }) - } - } - } - } - - override fun onItemDeleted(model: ImageAdapter.Model) { - - showDeleteImageDialog(model) - } - - override fun refreshLock() { - super.refreshLock() - (context as CollectActivity).traitLockData() - } } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt index af3411e44..013b42556 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt @@ -7,6 +7,7 @@ import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.view.View +import androidx.constraintlayout.widget.ConstraintLayout import com.fieldbook.tracker.devices.ptpip.PtpSessionCallback import com.fieldbook.tracker.objects.RangeObject import com.fieldbook.tracker.preferences.GeneralKeys @@ -15,12 +16,10 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import org.phenoapps.adapters.ImageAdapter @AndroidEntryPoint class CanonTrait : CameraTrait, - ImageAdapter.ImageItemHandler, PtpSessionCallback, WifiHelper.WifiRequester { @@ -40,6 +39,15 @@ class CanonTrait : defStyleAttr ) + override fun type(): String { + return type + } + + override fun loadLayout() { + super.loadLayout() + setup() + } + private fun setup() { activity?.runOnUiThread { @@ -48,6 +56,9 @@ class CanonTrait : imageView?.visibility = View.INVISIBLE + (imageView?.layoutParams as ConstraintLayout.LayoutParams) + .width = ConstraintLayout.LayoutParams.MATCH_PARENT + connectBtn?.visibility = View.VISIBLE if (controller.getCanonApi().isConnected) { @@ -96,11 +107,6 @@ class CanonTrait : } } - override fun loadLayout() { - super.loadLayout() - setup() - } - override fun onSessionStart() { uiScope.launch { diff --git a/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt index be4e76afb..080ffd6a6 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt @@ -1,75 +1,37 @@ package com.fieldbook.tracker.traits -import android.app.Activity import android.app.AlertDialog -import android.app.PendingIntent import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Point import android.graphics.SurfaceTexture -import android.hardware.usb.UsbManager -import android.net.Uri -import android.os.Build -import android.os.Handler -import android.os.Looper -import android.provider.DocumentsContract import android.util.AttributeSet -import android.util.Log +import android.view.Surface import android.view.View import android.widget.ImageView import android.widget.Toast import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.Group -import androidx.documentfile.provider.DocumentFile -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.fieldbook.tracker.R -import com.fieldbook.tracker.activities.CollectActivity -import com.fieldbook.tracker.database.models.ObservationModel -import com.fieldbook.tracker.preferences.GeneralKeys -import com.fieldbook.tracker.receivers.UsbAttachReceiver -import com.fieldbook.tracker.receivers.UsbDetachReceiver -import com.fieldbook.tracker.utilities.DocumentTreeUtil -import com.fieldbook.tracker.utilities.ExifUtil -import com.fieldbook.tracker.utilities.FileUtil -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.serenegiant.SimpleUVCCameraTextureView -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import org.phenoapps.adapters.ImageAdapter -import org.phenoapps.androidlibrary.Utils +import com.fieldbook.tracker.devices.camera.UsbCameraApi +import com.serenegiant.usb.Size +import com.serenegiant.usb.UVCCamera +import com.serenegiant.widget.CameraViewInterface +import com.serenegiant.widget.UVCCameraTextureView +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.phenoapps.interfaces.usb.camera.CameraSurfaceListener -import org.phenoapps.interfaces.usb.camera.UsbCameraInterface -import org.phenoapps.receivers.UsbPermissionReceiver -import org.phenoapps.usb.camera.UsbCameraHelper -import java.io.FileNotFoundException +import java.io.File +import java.util.UUID -class UsbCameraTraitLayout : BaseTraitLayout, ImageAdapter.ImageItemHandler { +@AndroidEntryPoint +class UsbCameraTraitLayout : CameraTrait, UsbCameraApi.Callbacks { companion object { const val TAG = "UsbTrait" const val type = "usb camera" - private const val CAMERA_DELAY_MS = 2000L } - private var activity: Activity? = null - private var mUsbPermissionReceiver: UsbPermissionReceiver? = null - private var mUsbDetachReceiver: UsbDetachReceiver? = null - private var mUsbAttachReceiver: UsbAttachReceiver? = null - private var mUsbCameraHelper: UsbCameraHelper? = null - private var textureView: SimpleUVCCameraTextureView? = null - private var connectBtn: FloatingActionButton? = null - private var captureBtn: FloatingActionButton? = null - private var recyclerView: RecyclerView? = null - private var previewGroup: Group? = null - private var constraintLayout: ConstraintLayout? = null - - //zoom buttons - private var zoomInButton: FloatingActionButton? = null - private var zoomOutButton: FloatingActionButton? = null + private var surface: Surface? = null + private var lastBitmap: Bitmap? = null constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) @@ -79,579 +41,162 @@ class UsbCameraTraitLayout : BaseTraitLayout, ImageAdapter.ImageItemHandler { defStyleAttr ) - override fun layoutId(): Int { - return R.layout.trait_usb_camera - } - - override fun setNaTraitsText() {} override fun type(): String { return type } - override fun init(act: Activity) { - - constraintLayout = act.findViewById(R.id.usb_camera_fragment_cv) - textureView = act.findViewById(R.id.usb_camera_fragment_tv) - connectBtn = act.findViewById(R.id.usb_camera_fragment_connect_btn) - captureBtn = act.findViewById(R.id.usb_camera_fragment_capture_btn) - recyclerView = act.findViewById(R.id.usb_camera_fragment_rv) - previewGroup = act.findViewById(R.id.usb_camera_fragment_preview_group) - zoomInButton = act.findViewById(R.id.usb_camera_fragment_plus_btn) - zoomOutButton = act.findViewById(R.id.usb_camera_fragment_minus_btn) - - activity = act - - mUsbCameraHelper = (activity as? UsbCameraInterface)?.getCameraHelper() - - recyclerView?.adapter = ImageAdapter(this) - - connectBtn?.setOnClickListener { - - context?.let { ctx -> - - val manager = ctx.getSystemService(Context.USB_SERVICE) as UsbManager - - Log.d(TAG, "manager: $manager") - - val permissionIntent = PendingIntent.getBroadcast( - ctx, - 0, - Intent(UsbPermissionReceiver.ACTION_USB_PERMISSION), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 - ) - - val devices = manager.deviceList.map { it.value } - Log.d(TAG, "devices: $devices ${devices.size}") - - devices.forEach { - Log.d(TAG, "${it.vendorId} ${it.productId}") - Log.d(TAG, it.deviceName) - manager.requestPermission(it, permissionIntent) - } - - if (devices.isNotEmpty()) { - - setup() - } - } - } - - textureView?.setOnClickListener { - - mUsbCameraHelper?.setFocus() - - } - - zoomOutButton?.setOnClickListener { - - try { - - val current = mUsbCameraHelper?.getZoom() ?: 1 - - if (current < Int.MAX_VALUE) { - - mUsbCameraHelper?.setZoom(current) - - } - - } catch (e: Exception) { - - e.printStackTrace() - - Log.d(TAG, "Something went wrong with zooming USB Camera.") - - } - } - - zoomInButton?.setOnClickListener { - - try { - - val current = mUsbCameraHelper?.getZoom() ?: 1 - - if (current > 1) { - - mUsbCameraHelper?.setZoom(current - 1) - - } - - } catch (e: Exception) { - - e.printStackTrace() - - Log.d(TAG, "Something went wrong with zooming USB Camera.") - - } - } - - registerReconnectListener() - - connectBtn?.requestFocus() + override fun loadLayout() { + super.loadLayout() + setup() } - private fun registerReconnectListener() { - - try { - - context?.unregisterReceiver(mUsbPermissionReceiver) + override fun onConnected(camera: UVCCamera?, sizes: List) { - } catch (ignore: Exception) {} + sizes.maxByOrNull { it.height * it.width }?.let { size -> - mUsbPermissionReceiver = UsbPermissionReceiver { - - Log.d(TAG, "Permission result $it") - - previewGroup?.visibility = View.VISIBLE - - try { - - context.unregisterReceiver(mUsbPermissionReceiver) - - } catch (ignore: Exception) { - } //might not be registered if already paired - - setup() + setCameraPreviewSize(camera, size) + updatePreviewSize(size.width, size.height) } - val filter = IntentFilter(UsbPermissionReceiver.ACTION_USB_PERMISSION) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(mUsbPermissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.registerReceiver(mUsbPermissionReceiver, filter) - } + startCaptureUi(camera, sizes) } - private fun registerDetachListener() { - - try { - - context?.unregisterReceiver(mUsbDetachReceiver) - - } catch (ignore: Exception) {} - - try { - - val detachFilter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED) - - mUsbDetachReceiver = UsbDetachReceiver { - - (context as CollectActivity).usbCameraConnected = false - - Log.d(TAG, "Detaching") - - activity?.runOnUiThread { - - previewGroup?.visibility = View.GONE - - registerAttachListener() - } - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver( - mUsbDetachReceiver, - detachFilter, - Context.RECEIVER_NOT_EXPORTED - ) - } else { - context.registerReceiver(mUsbDetachReceiver, detachFilter) - } - - } catch (e: Exception) { - - e.printStackTrace() - + override fun onDisconnected() { + ui.launch { + initUi() } } - private fun registerAttachListener() { - - try { - - context?.unregisterReceiver(mUsbAttachReceiver) - - } catch (ignore: Exception) {} - - mUsbAttachReceiver = UsbAttachReceiver { - - Log.d(TAG, "Usb attach") - - previewGroup?.visibility = VISIBLE - - try { - - context.unregisterReceiver(mUsbAttachReceiver) - - } catch (ignore: Exception) { - } //might not be registered if already paired - } - - val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(mUsbAttachReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.registerReceiver(mUsbAttachReceiver, filter) - } + override fun getUsbCameraInterface(): CameraViewInterface { + return controller.getUvcView() } - private fun setup() { - - Log.d(TAG, "Setup") - - connectBtn?.visibility = View.GONE - - previewGroup?.visibility = View.VISIBLE - - textureView?.surfaceTextureListener = object : CameraSurfaceListener { - - override fun onSurfaceTextureAvailable( - surface: SurfaceTexture, - width: Int, - height: Int - ) { - - Log.d(TAG, "Surface available..") - - initPreview() - - (context as CollectActivity).usbCameraConnected = true - - } - - override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {} - } - - if (textureView?.isAvailable == true) { - - Log.d(TAG, "Surface available already.") - - initPreview() - - } + override fun getPreviewView(): ImageView? { + return imageView } - private fun initPreview() { - - Log.d(TAG, "Init preview") - - registerDetachListener() - - textureView?.let { tv -> - - mUsbCameraHelper?.let { helper -> - - previewGroup?.visibility = View.VISIBLE - - Log.d(TAG, "Helper init") - - helper.init(tv) { ratio -> - - activity?.runOnUiThread { - - textureView?.setAspectRatio(ratio) - - captureBtn?.invalidate() - - } - } - - captureBtn?.setOnClickListener { - - if (!isLocked) { + private fun setup() { - captureBtn?.isEnabled = false + (imageView?.layoutParams as ConstraintLayout.LayoutParams) + .width = ConstraintLayout.LayoutParams.WRAP_CONTENT - runBlocking { + surface = Surface(controller.getUvcView().surfaceTexture) - Log.d(TAG, "Capture click.") + if (controller.getUsbApi().isConnected()) { - saveBitmapToStorage() + startCaptureUi(controller.getUsbApi().camera, + controller.getUsbApi().getSizes()) - delay(CAMERA_DELAY_MS) + } else initUi() - scrollToLast() + controller.getUsbApi().attachToTraitLayout(this) - } - } - } - } - } } - private fun scrollToLast() { - - try { - - recyclerView?.postDelayed({ - - val pos = recyclerView?.adapter?.itemCount ?: 1 + private fun initUi() { - recyclerView?.scrollToPosition(pos - 1) + captureBtn?.visibility = View.INVISIBLE - captureBtn?.isEnabled = true + connectBtn?.visibility = View.VISIBLE - }, 500L) - - - } catch (e: Exception) { + connectBtn?.setOnClickListener { - e.printStackTrace() + Toast.makeText(context, R.string.usb_camera_plug_in_camera, Toast.LENGTH_SHORT).show() } } - override fun loadLayout() { - - //slight delay to make navigation a bit faster - Handler(Looper.getMainLooper()).postDelayed({ - - loadAdapterItems() - - }, 500) - - if ((context as CollectActivity).usbCameraConnected) { - - try { - - setup() - - } catch (e: Exception) { - - e.printStackTrace() - - } + private fun showResolutionChoiceDialog(sizes: List) { + val widthByHeightListValues = sizes.map { "${it.width}x${it.height}" } + .distinct() + .toTypedArray() + val dialog = AlertDialog.Builder(context) + dialog.setTitle(R.string.usb_camera_resolution_options_title) + dialog.setItems(widthByHeightListValues) { _, which -> + updatePreviewSize(sizes[which].width, sizes[which].height) } - - super.loadLayout() + dialog.show() } - override fun deleteTraitListener() { - - if (!isLocked) { + private fun setCameraPreviewSize(camera: UVCCamera?, size: Size) { - (recyclerView?.layoutManager as? LinearLayoutManager)?.findFirstCompletelyVisibleItemPosition()?.let { index -> - - if (index > -1) { - - (recyclerView?.adapter as? ImageAdapter)?.currentList?.get(index)?.let { model -> - - showDeleteImageDialog(model) - - } - } - } - } + camera?.setPreviewSize(size.width, size.height) + camera?.setPreviewDisplay(surface) + camera?.startPreview() } - private fun showDeleteImageDialog(model: ImageAdapter.Model) { - - if (!isLocked) { - context.contentResolver.openInputStream(Uri.parse(model.uri)).use { input -> - - val imageView = ImageView(context) - imageView.setImageBitmap(BitmapFactory.decodeStream(input)) - - AlertDialog.Builder(context, R.style.AppAlertDialog) - .setTitle(R.string.delete_local_photo) - .setOnCancelListener { dialog -> dialog.dismiss() } - .setPositiveButton(android.R.string.ok) { dialog, _ -> + private fun updatePreviewSize(width: Int, height: Int) { - dialog.dismiss() + ui.launch { + controller?.getUvcView()?.aspectRatio = width / height.toDouble() - deleteItem(model) + (controller?.getUvcView() as UVCCameraTextureView).layoutParams = ConstraintLayout.LayoutParams( + width, height + ) - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } - .setView(imageView) - .show() + ((controller?.getUvcView() as UVCCameraTextureView).layoutParams as ConstraintLayout.LayoutParams).let { params -> + params.topToBottom = ConstraintLayout.LayoutParams.PARENT_ID + params.startToEnd = ConstraintLayout.LayoutParams.PARENT_ID } } } - private fun saveBitmapToStorage() { - - //get current trait's trait name, use it as a plot_media directory - currentTrait.name?.let { traitName -> + private fun startCaptureUi(camera: UVCCamera?, sizes: List?) { - val sanitizedTraitName = FileUtil.sanitizeFileName(traitName) + ui.launch { - val traitDbId = currentTrait.id + captureBtn?.visibility = View.VISIBLE + connectBtn?.visibility = View.INVISIBLE - //get the bitmap from the texture view, only use it if its not null - textureView?.bitmap?.let { bmp -> + captureBtn?.setOnClickListener { - DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName) - ?.let { usbPhotosDir -> + captureBtn?.isEnabled = false - val plot = collectActivity.observationUnit - val studyId = collectActivity.studyId - val time = Utils.getDateTime() - val name = "${sanitizedTraitName}_${plot}_$time.png" + val file = File(context.cacheDir, "${UUID.randomUUID()}.png") - usbPhotosDir.createFile("*/*", name)?.let { file -> + with (controller.getUsbApi()) { - context.contentResolver.openOutputStream(file.uri)?.let { output -> + background.launch { - //if sdk > 24, can write exif information to the image - //goal is to encode observation variable model into the user comments - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + lastBitmap?.let { bmp -> - ExifUtil.saveVariableUnitModelToExif( - context, - (controller.getContext() as CollectActivity).person, - time, - database.getStudyById(studyId), - database.getObservationUnitById(currentRange.plot_id), - database.getObservationVariableById(currentTrait.id), - file.uri - ) - } + saveBitmapToStorage(bmp, currentRange) - bmp.compress(Bitmap.CompressFormat.PNG, 100, output) + activity?.runOnUiThread { - database.insertObservation( - plot, traitDbId, type, file.uri.toString(), - (activity as? CollectActivity)?.person, - (activity as? CollectActivity)?.locationByPreferences, "", studyId, - null, - null, - null - ) - - //if sdk > 24, can write exif information to the image - //goal is to encode observation variable model into the user comments - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - - ExifUtil.saveJsonToExif( - context, - currentTrait, - file.uri - ) + captureBtn?.isEnabled = true + file.delete() } } } - - loadAdapterItems() } } - } - } - - private fun loadAdapterItems() { - - val thumbnailModels = getImageObservations().mapNotNull { - var model: ImageAdapter.Model? = null + imageView?.setOnClickListener { - try { - - DocumentsContract.getDocumentThumbnail(context.contentResolver, - Uri.parse(it.value), Point(256, 256), null)?.let { bmp -> - - model = ImageAdapter.Model(it.value, bmp) + if (camera != null && sizes?.isNotEmpty() == true) { + showResolutionChoiceDialog(sizes) } - - } catch (f: FileNotFoundException) { - - f.printStackTrace() - - model = null } - model - } - - (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) - } - - private fun getImageObservations(): Array { - - val traitDbId = collectActivity.traitDbId.toInt() - val plot = collectActivity.observationUnit - val studyId = collectActivity.studyId - - return database.getAllObservations(studyId).filter { - it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot - }.toTypedArray() - } - - private fun deleteItem(model: ImageAdapter.Model) { - - val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() - - //get current trait's trait name, use it as a plot_media directory - currentTrait?.name?.let { traitName -> - - val plot = currentRange.plot_id - - val traitDbId = currentTrait.id - - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> - - try { - - DocumentFile.fromSingleUri(context, Uri.parse(observation.value)) - ?.let { image -> - - val result = image.delete() - - if (result) { - - database.deleteTraitByValue( - studyId, - plot, - traitDbId, - image.uri.toString() - ) - - loadAdapterItems() - - } else { - - collectActivity.runOnUiThread { - - Toast.makeText(context, R.string.photo_failed_to_delete, Toast.LENGTH_SHORT).show() + controller.getUvcView().surfaceTextureListener = + object : CameraSurfaceListener { + override fun onSurfaceTextureAvailable( + surface: SurfaceTexture, + width: Int, + height: Int + ) { + } - } - } + override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) { + controller.getUvcView().bitmap?.let { bmp -> + imageView?.setImageBitmap(bmp) + lastBitmap = bmp } - - } catch (e: Exception) { - - Log.e(TAG, "Failed to delete images.", e) - - } - } - } - } - - override fun onItemClicked(model: ImageAdapter.Model) { - - if (!isLocked) { - - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> - - DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> - - activity?.startActivity(Intent(Intent.ACTION_VIEW, image.uri).also { - it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - }) + } } - } } } - - override fun onItemDeleted(model: ImageAdapter.Model) { - - showDeleteImageDialog(model) - } - - override fun refreshLock() { - super.refreshLock() - (context as CollectActivity).traitLockData() - } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_collect.xml b/app/src/main/res/layout/activity_collect.xml index ebf859fa5..7768536d5 100644 --- a/app/src/main/res/layout/activity_collect.xml +++ b/app/src/main/res/layout/activity_collect.xml @@ -81,6 +81,14 @@ + + - + android:id="@+id/camera_fragment_cv"> + app:layout_constraintTop_toBottomOf="@id/camera_fragment_rv" + android:contentDescription="@string/trait_camera_preview_content_description" /> + android:contentDescription="@string/trait_camera_capture_content_description" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01ebbfc89..70490e116 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1048,5 +1048,10 @@ This will open a URL to the FieldBook Canon manual. Debug A preferences for developers to monitor device output. + Supported Resolutions + Please plug in a UVC compatible web cam. + Starts a device connection. + This previews the camera\'s display. + Capture a photo \ No newline at end of file From 4d63f8f5f2e0ae0ab29dbaef222e823a5277e1fc Mon Sep 17 00:00:00 2001 From: chaneylc Date: Sat, 2 Mar 2024 14:26:20 -0600 Subject: [PATCH 5/6] improved wifi connectivity --- .../fieldbook/tracker/traits/CanonTrait.kt | 6 +- .../fieldbook/tracker/utilities/WifiHelper.kt | 154 ++++++------------ 2 files changed, 50 insertions(+), 110 deletions(-) diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt index 013b42556..a1f108de7 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt @@ -50,6 +50,8 @@ class CanonTrait : private fun setup() { + val ssid = preferences.getString(GeneralKeys.CANON_SSID_NAME, "Canon") ?: "Canon" + activity?.runOnUiThread { captureBtn?.visibility = View.INVISIBLE @@ -65,7 +67,7 @@ class CanonTrait : startCanonSession() - } else controller.getWifiHelper().startWifiSearch(this) + } else controller.getWifiHelper().startWifiSearch(ssid, this) connectBtn?.setOnClickListener { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -80,7 +82,7 @@ class CanonTrait : controller.getWifiHelper().disconnect() - controller.getWifiHelper().startWifiSearch(this) + controller.getWifiHelper().startWifiSearch(ssid, this) } } diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt index ad58b32ea..81c7126a3 100644 --- a/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt +++ b/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt @@ -1,23 +1,23 @@ package com.fieldbook.tracker.utilities -import android.content.BroadcastReceiver import android.content.Context -import android.content.Intent -import android.content.IntentFilter import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest -import android.net.wifi.WifiManager import android.net.wifi.WifiNetworkSpecifier import android.os.Build -import android.os.Handler -import android.os.Looper +import android.os.PatternMatcher +import androidx.annotation.RequiresApi import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class WifiHelper @Inject constructor(@ApplicationContext private val context: Context) { + companion object { + const val TAG = "WifiHelper" + } + interface WifiRequester { fun getSsidName(): String fun onNetworkBound() @@ -25,134 +25,72 @@ class WifiHelper @Inject constructor(@ApplicationContext private val context: Co private var requester: WifiRequester? = null - private val wifiManager by lazy { - - context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager - + private val connectivityManager by lazy { + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager } - fun disconnect() { - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + private val networkCallback = object : ConnectivityManager.NetworkCallback() { - val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + @RequiresApi(Build.VERSION_CODES.M) + override fun onAvailable(network: Network) { + super.onAvailable(network) - connectivityManager.bindProcessToNetwork(null) - } - } - - fun startWifiSearch(requester: WifiRequester) { - - this.requester = requester + val bound = connectivityManager.bindProcessToNetwork(network) - val intentFilter = IntentFilter() - intentFilter.addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) - intentFilter.addAction(WifiManager.NETWORK_STATE_CHANGED_ACTION) + requester?.onNetworkBound() - try { - context.unregisterReceiver(wifiReceiver) - } catch (_: Exception) { } - - context.registerReceiver(wifiReceiver, intentFilter) - - val success = wifiManager.startScan() - if (!success) { - // scan failure handling - scanFailure() } - } - - fun stopWifiSearch() { - - try { - - context.unregisterReceiver(wifiReceiver) - } catch (_: Exception) {} + @RequiresApi(Build.VERSION_CODES.M) + override fun onLost(network: Network) { + super.onLost(network) + connectivityManager.bindProcessToNetwork(null) + } } - private fun scanSuccess() { - - val results = wifiManager.scanResults - - results.forEach { result -> + fun disconnect() { - val name = this.requester?.getSsidName() ?: "" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - if (name in result.SSID) { + connectivityManager.bindProcessToNetwork(null) - startWifiSpecifier(result.SSID?.toString(), result.BSSID?.toString()) - } + connectivityManager.unregisterNetworkCallback(networkCallback) } } - private fun scanFailure() { - - val results = wifiManager.scanResults - - } - - private fun startWifiSpecifier(ssid: String?, bssid: String?) { + fun startWifiSearch(format: String, requester: WifiRequester) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + this.requester = requester - val specifier = + val specifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { WifiNetworkSpecifier.Builder() - .setSsid(ssid!!) + .setSsidPattern( + PatternMatcher( + ".*$format.*", + PatternMatcher.PATTERN_SIMPLE_GLOB + ) + ) + //.setSsid(ssid!!) //adding BSSID will remove the need for the "connect" dialog //.setBssid(MacAddress.fromString(bssid!!)) .build() - - val request = NetworkRequest.Builder() - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) - .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .setNetworkSpecifier(specifier) - .build() - - val connectivityManager = - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - val networkCallback = object : ConnectivityManager.NetworkCallback() { - - override fun onAvailable(network: Network) { - super.onAvailable(network) - - val bound = connectivityManager.bindProcessToNetwork(network) - - requester?.onNetworkBound() - - stopWifiSearch() - } + } else { + TODO("VERSION.SDK_INT < Q") } - connectivityManager.requestNetwork( - request, - networkCallback, - Handler(Looper.getMainLooper()), - 10000 - ) - } - } - - private val wifiReceiver = object : BroadcastReceiver() { - - override fun onReceive(context: Context, intent: Intent) { + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(specifier) + .build() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + //connectivityManager.registerNetworkCallback(request, networkCallback) - val success = intent.getBooleanExtra(WifiManager.EXTRA_RESULTS_UPDATED, false) + connectivityManager.requestNetwork( + request, + networkCallback + ) - if (success) { - - scanSuccess() - - } else { - - scanFailure() - - } - } - } } } \ No newline at end of file From 056fa8779482a39c0afba30152678d9101df6c2e Mon Sep 17 00:00:00 2001 From: chaneylc Date: Wed, 13 Mar 2024 09:29:30 -0500 Subject: [PATCH 6/6] gopro updates --- app/build.gradle | 7 +- .../tracker/activities/CollectActivity.java | 48 +- .../tracker/devices/camera/GoProApi.kt | 503 ++++++++++ .../tracker/interfaces/CollectController.kt | 8 + .../tracker/traits/AbstractCameraTrait.kt | 63 +- .../fieldbook/tracker/traits/CameraTrait.kt | 5 + .../fieldbook/tracker/traits/CanonTrait.kt | 6 +- .../tracker/traits/GoProTraitLayout.kt | 921 +++--------------- .../tracker/traits/UsbCameraTraitLayout.kt | 2 +- .../tracker/utilities/BluetoothHelper.kt | 95 ++ .../tracker/utilities/FfmpegHelper.kt | 142 +++ .../tracker/utilities/GoProWrapper.kt | 98 -- .../fieldbook/tracker/utilities/WifiHelper.kt | 58 +- app/src/main/res/layout/trait_camera.xml | 17 + app/src/main/res/values/strings.xml | 2 + 15 files changed, 1020 insertions(+), 955 deletions(-) create mode 100644 app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/utilities/BluetoothHelper.kt create mode 100644 app/src/main/java/com/fieldbook/tracker/utilities/FfmpegHelper.kt delete mode 100644 app/src/main/java/com/fieldbook/tracker/utilities/GoProWrapper.kt diff --git a/app/build.gradle b/app/build.gradle index ea898e73e..6737035d6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -149,7 +149,12 @@ dependencies { implementation('com.github.phenoapps:phenolib:v0.9.51-SNAPSHOT') - implementation 'com.google.android.exoplayer:exoplayer:2.19.1' + //implementation 'com.google.android.exoplayer:exoplayer:2.19.1' + implementation 'androidx.media3:media3-exoplayer:1.3.0' + implementation 'androidx.media3:media3-exoplayer-dash:1.3.0' + implementation 'androidx.media3:media3-ui:1.3.0' + implementation 'androidx.media3:media3-common:1.3.0' + implementation 'com.arthenica:ffmpeg-kit-min:5.1.LTS' implementation 'com.squareup.okhttp3:okhttp:4.11.0' diff --git a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java index 07b207d95..3f6df50fb 100644 --- a/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java +++ b/app/src/main/java/com/fieldbook/tracker/activities/CollectActivity.java @@ -10,7 +10,6 @@ import android.content.res.Configuration; import android.location.Location; import android.net.Uri; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.HandlerThread; @@ -39,6 +38,8 @@ import androidx.constraintlayout.widget.ConstraintLayout; import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.FragmentManager; +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -50,6 +51,7 @@ import com.fieldbook.tracker.database.models.ObservationModel; import com.fieldbook.tracker.database.models.ObservationUnitModel; import com.fieldbook.tracker.devices.camera.CanonApi; +import com.fieldbook.tracker.devices.camera.GoProApi; import com.fieldbook.tracker.devices.camera.UsbCameraApi; import com.fieldbook.tracker.dialogs.GeoNavCollectDialog; import com.fieldbook.tracker.interfaces.FieldSwitcher; @@ -64,17 +66,17 @@ import com.fieldbook.tracker.traits.CanonTrait; import com.fieldbook.tracker.traits.CategoricalTraitLayout; import com.fieldbook.tracker.traits.GNSSTraitLayout; -import com.fieldbook.tracker.traits.GoProTraitLayout; import com.fieldbook.tracker.traits.LayoutCollections; import com.fieldbook.tracker.traits.PhotoTraitLayout; +import com.fieldbook.tracker.utilities.BluetoothHelper; import com.fieldbook.tracker.utilities.CategoryJsonUtil; import com.fieldbook.tracker.utilities.DocumentTreeUtil; +import com.fieldbook.tracker.utilities.FfmpegHelper; import com.fieldbook.tracker.utilities.FieldAudioHelper; import com.fieldbook.tracker.utilities.FieldSwitchImpl; import com.fieldbook.tracker.utilities.GeoJsonUtil; import com.fieldbook.tracker.utilities.GeoNavHelper; import com.fieldbook.tracker.utilities.GnssThreadHelper; -import com.fieldbook.tracker.utilities.GoProWrapper; import com.fieldbook.tracker.utilities.InfoBarHelper; import com.fieldbook.tracker.utilities.JsonUtil; import com.fieldbook.tracker.utilities.KeyboardListenerHelper; @@ -135,7 +137,6 @@ public class CollectActivity extends ThemedActivity com.fieldbook.tracker.interfaces.CollectRangeController, com.fieldbook.tracker.interfaces.CollectTraitController, InfoBarAdapter.InfoBarController, - GoProTraitLayout.GoProCollector, GPSTracker.GPSTrackerListener { public static final int REQUEST_FILE_EXPLORER_CODE = 1; @@ -146,6 +147,12 @@ public class CollectActivity extends ThemedActivity private GeoNavHelper geoNavHelper; + @Inject + FfmpegHelper ffmpegHelper; + + @Inject + GoProApi goProApi; + @Inject UsbCameraApi usbCameraApi; @@ -155,6 +162,9 @@ public class CollectActivity extends ThemedActivity @Inject WifiHelper wifiHelper; + @Inject + BluetoothHelper bluetoothHelper; + @Inject KeyboardListenerHelper keyboardListenerHelper; @@ -183,9 +193,6 @@ public class CollectActivity extends ThemedActivity @Inject SoundHelperImpl soundHelper; - @Inject - GoProWrapper goProWrapper; - private GPSTracker gps; public static boolean searchReload; @@ -323,8 +330,6 @@ public void handleMessage(Message msg) { mUsbCameraHelper = new UsbCameraHelper(this); - goProWrapper.attach(); - mlkitEnabled = mPrefs.getBoolean(GeneralKeys.MLKIT_PREFERENCE_KEY, false); loadScreen(); @@ -339,12 +344,14 @@ public void handleMessage(Message msg) { protected void onStart() { super.onStart(); usbCameraApi.onStart(); + bluetoothHelper.onStart(); } @Override protected void onStop() { super.onStop(); usbCameraApi.onStop(); + bluetoothHelper.onStop(); } public void triggerTts(String text) { @@ -533,6 +540,7 @@ private void loadScreen() { refreshInfoBarAdapter(); uvcView = findViewById(R.id.collect_activity_uvc_tv); + } //when softkeyboard is displayed, reset the snackbar to redisplay with a calculated bottom margin @@ -954,6 +962,8 @@ public void onPause() { ep.edit().putInt(GeneralKeys.DATA_LOCK_STATE, dataLocked).apply(); + bluetoothHelper.onPause(); + super.onPause(); } @@ -977,14 +987,16 @@ public void onDestroy() { mUsbCameraHelper.destroy(); - goProWrapper.destroy(); - traitLayoutRefresh(); gnssThreadHelper.stop(); usbCameraApi.onDestroy(); + goProApi.onDestroy(); + + bluetoothHelper.onDestroy(); + super.onDestroy(); } @@ -2311,8 +2323,8 @@ public GnssThreadHelper getGnssThreadHelper() { @NonNull @Override - public GoProWrapper wrapper() { - return goProWrapper; + public GoProApi getGoProApi() { + return goProApi; } @NonNull @@ -2578,6 +2590,10 @@ public CanonApi getCanonApi() { @Override public WifiHelper getWifiHelper() { return wifiHelper; } + @NonNull + @Override + public BluetoothHelper getBluetoothHelper() { return bluetoothHelper; } + @NonNull @Override public UsbCameraApi getUsbApi() { @@ -2587,4 +2603,10 @@ public UsbCameraApi getUsbApi() { @NonNull @Override public UVCCameraTextureView getUvcView() { return uvcView; } + + @NonNull + @Override + public FfmpegHelper getFfmpegHelper() { + return ffmpegHelper; + } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt b/app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt new file mode 100644 index 000000000..9937241fa --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/devices/camera/GoProApi.kt @@ -0,0 +1,503 @@ +package com.fieldbook.tracker.devices.camera + +import android.bluetooth.BluetoothDevice +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Log +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import com.fieldbook.tracker.interfaces.CollectController +import com.fieldbook.tracker.objects.RangeObject +import com.fieldbook.tracker.objects.TraitObject +import com.fieldbook.tracker.utilities.WifiHelper +import dagger.hilt.android.qualifiers.ActivityContext +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.json.JSONException +import org.json.JSONObject +import org.phenoapps.fragments.gopro.GoProGatt +import org.phenoapps.fragments.gopro.GoProGattInterface +import org.phenoapps.interfaces.gatt.GattCallbackInterface +import java.net.URI +import javax.inject.Inject + + +@UnstableApi +class GoProApi @Inject constructor( + @ActivityContext private val context: Context +) : + GattCallbackInterface, + GoProGattInterface, + GoProGatt.GoProGattController, + WifiHelper.WifiRequester { + + data class GoProImage( + val fileDir: String, + val fileName: String, + val mod: Long, + val byteSize: Long, + val url: String + ) + + data class ImageRequestData( + val studyId: String, + val range: RangeObject, + val trait: TraitObject, + val time: String + ) + + interface Callbacks { + fun onConnected() + fun onInitializeGatt() + fun onStreamReady() + fun onStreamRequested() + fun onImageRequestReady(bitmap: Bitmap, data: ImageRequestData) + } + + companion object { + const val TAG = "GoProApi" + private const val ffmpegOutputUri = "udp://@localhost:8555" + } + + private val gatt by lazy { + GoProGatt(this) + } + + private val controller by lazy { + context as CollectController + } + + private val httpClient by lazy { + OkHttpClient() + } + + private val loadControl: androidx.media3.exoplayer.DefaultLoadControl = + androidx.media3.exoplayer.DefaultLoadControl.Builder() + .setPrioritizeTimeOverSizeThresholds(true) + .setBufferDurationsMs(500, 1000, 500, 500) + .build() + + private val mediaSource: androidx.media3.exoplayer.source.MediaSource = + androidx.media3.exoplayer.source.ProgressiveMediaSource.Factory( + androidx.media3.datasource.DefaultDataSource.Factory(context) + ).createMediaSource( + androidx.media3.common.MediaItem.fromUri( + Uri.parse(ffmpegOutputUri) + ) + ) + + private val trackSelector: androidx.media3.exoplayer.trackselection.DefaultTrackSelector = + androidx.media3.exoplayer.trackselection.DefaultTrackSelector(context) + + private val playerListener: Player.Listener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + super.onPlaybackStateChanged(playbackState) + when (playbackState) { + Player.STATE_IDLE, Player.STATE_ENDED -> Log.d( + TAG, "Player Idle/Ended" + ) + + Player.STATE_BUFFERING -> if (!streamStarted) { + Log.d(TAG, "Player Buffering") + Log.d(TAG, "Requesting start stream.") + streamStarted = true + } + + Player.STATE_READY -> { + Log.d(TAG, "Player Ready") + callbacks?.onStreamReady() + } + } + } + } + + private var player: ExoPlayer? = null + private var callbacks: Callbacks? = null + + var streamStarted = false + + fun requestStream() { + + //stop stream first, on fail or success start stream again: + val stopPreview: Request = Request.Builder() + .url(URI.create("http://10.5.5.9:8080/gopro/camera/stream/stop").toHttpUrlOrNull()!!) + .build() + + httpClient.newCall(stopPreview).enqueue(object : Callback { + + override fun onFailure(call: okhttp3.Call, e: okio.IOException) { + Log.e(TAG, "Request stop failed.") + e.printStackTrace() + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + if (!response.isSuccessful) { + requestStartStream() + Log.e(TAG, "Request stop preview response = not success") + } else { + requestStartStream() + Log.i(TAG, "Request stop preview response = success") + } + response.close() + } + }) + } + + /** + * Makes http request to start go pro stream. + * If request is successfull it starts the keep alive background routine + */ + fun requestStartStream() { + + Log.d(TAG, "Request stream start.") + + val startPreview: Request = Request.Builder() + .url(URI.create("http://10.5.5.9:8080/gopro/camera/stream/start").toHttpUrlOrNull()!!) + .build() + + httpClient.newCall(startPreview).enqueue(object : Callback { + + override fun onFailure(call: okhttp3.Call, e: okio.IOException) { + Log.e(TAG, "Failed to make network request to GoPro AP") + e.printStackTrace() + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + if (!response.isSuccessful) { + Log.e(TAG, "Request response = not success ${response.code}") + controller.getFfmpegHelper().initRequestTimer() + callbacks?.onStreamRequested() + } else { + Log.i(TAG, "Request response = success") + controller.getFfmpegHelper().initRequestTimer() + callbacks?.onStreamRequested() + } + response.close() + } + }) + } + + /** + * http request to read media list (files on gopro device) + */ + fun queryMedia(model: ImageRequestData) { + + Log.d(TAG, "Attempting media list query.") + + //stop stream first, on fail or success start stream again: + val mediaQuery: Request = Request.Builder() + .url(URI.create("http://10.5.5.9:8080/gopro/media/list").toHttpUrlOrNull()!!) + .build() + + httpClient.newCall(mediaQuery).enqueue(object : Callback { + + override fun onFailure(call: okhttp3.Call, e: okio.IOException) { + + Log.e(TAG, "Media query failed.") + + e.printStackTrace() + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + + if (!response.isSuccessful) { + + Log.e(TAG, "Media query not success") + + } else { + + parseMediaQueryResponse(response.body?.string() ?: "{}", model) + + Log.i(TAG, "Media query success.") + + } + + response.close() + } + }) + } + + /** + * Makes http request to stop stream and cancel necessary threads. + */ + private fun stopStream() { + + Log.d(TAG, "Attempting stop preview request.") + + val stopPreview: Request = Request.Builder() + .url(URI.create("http://10.5.5.9:8080/gopro/camera/stream/stop").toHttpUrlOrNull()!!) + .build() + + httpClient.newCall(stopPreview).enqueue(object : Callback { + + override fun onFailure(call: okhttp3.Call, e: okio.IOException) { + Log.e(TAG, "Request stop failed.") + e.printStackTrace() + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + if (!response.isSuccessful) { + Log.e(TAG, "Request stop preview response = not success") + } else { + Log.i(TAG, "Request stop preview response = success") + } + response.close() + } + }) + + controller.getFfmpegHelper().cancel() + + } + + fun onDestroy() { + + stopStream() + + controller.getFfmpegHelper().cancel() + + controller.getWifiHelper().disconnect() + + gatt.clear() + + } + + fun onConnect(device: BluetoothDevice, callbacks: Callbacks) { + + this.callbacks = callbacks + + device.connectGatt(context, false, gatt.callback) + + callbacks.onInitializeGatt() + } + + fun initialize() { + //reset ui component states + player?.stop() + player?.release() + player?.clearMediaItems() + player?.clearVideoSurface() + player = null + //reset global flags + this.streamStarted = false + + } + + fun isStreamStarted(): Boolean = streamStarted + + fun createPlayer(): ExoPlayer { + + //Max. Buffer: The maximum duration, in milliseconds, of the media the player is attempting to buffer. Once the buffer reaches Max Buffer, it will stop filling it up. + //min Buffer: The minimum length of media that the player will ensure is buffered at all times, in milliseconds. + //Playback Buffer: The default amount of time, in milliseconds, of media that needs to be buffered in order for playback to start or resume after a user action such as a seek. + //Buffer for playback after rebuffer: The duration of the media that needs to be buffered in order for playback to continue after a rebuffer, in milliseconds. + player?.stop() + player?.release() + player = null + + player = ExoPlayer.Builder(context) + .setTrackSelector(trackSelector) + .setLoadControl(loadControl) + .build() + + player?.addListener(playerListener) + player?.setMediaSource(mediaSource) + player?.playWhenReady = true + player?.prepare() + + return player as ExoPlayer + + } + + /** + * parses media list response and returns the last requests the most recent file + */ + private fun parseMediaQueryResponse( + responseBody: String, + model: ImageRequestData + ) { + + try { + + val json = JSONObject(responseBody) + + Log.d(TAG, json.toString(1)) + + val mediaArray = json.getJSONArray("media") + + val size = mediaArray.length() + + val images = arrayListOf() + + for (i in 0 until size) { + + val media = mediaArray.getJSONObject(i) + + val dir = media.getString("d") + + val files = media.getJSONArray("fs") + + val numFiles = files.length() + + val fileName = files.getJSONObject(files.length() - 1).getString("n") + + requestFileUrl("http://10.5.5.9:8080/videos/DCIM/$dir/$fileName", model) + + break +// println(files) +// +// for (j in 0 until numFiles) { +// +// val file = files.getJSONObject(j) +// +// val fileName = file.getString("n") +// +// images.add( +// GoProImage( +// dir, +// fileName, +// file.getString("mod").toLong(), +// file.getString("s").toLong(), +// "http://10.5.5.9:8080/videos/DCIM/$dir/$fileName" +// ) +// ) +// } + } + + //val latest = images.maxBy { it.mod }.url + + //requestFileUrl(latest, model) + + } catch (e: JSONException) { + + e.printStackTrace() + + } + } + + /** + * requests the image at the given url, calls onReady interface when image is downloaded + */ + private fun requestFileUrl(url: String, model: ImageRequestData) { + + Log.d(TAG, "Image request: $url") + + //stop stream first, on fail or success start stream again: + val requestImage: Request = Request.Builder() + .url(URI.create(url).toHttpUrlOrNull()!!) + .build() + + httpClient.newCall(requestImage).enqueue(object : Callback { + + override fun onFailure(call: okhttp3.Call, e: okio.IOException) { + + Log.e(TAG, "Request image failed.") + + e.printStackTrace() + } + + override fun onResponse(call: okhttp3.Call, response: Response) { + + if (!response.isSuccessful) { + + Log.e(TAG, "Request image response = not success") + + } else { + + Log.i(TAG, "Request image response = success") + + response.body?.byteStream()?.use { inputStream -> + + callbacks?.onImageRequestReady( + BitmapFactory.decodeStream(inputStream), + model + ) + + } + } + + response.close() + } + }) + } + + override fun onApRequested() {} + + override fun onBoardType(boardType: String) {} + + override fun onBssid(wifiBSSID: String) {} + + /** + * Collect activity callback region + */ + override fun onCredentialsAcquired() { + + try { + + Log.d(TAG, "onCredentialsAcquired ${gatt.ssid} ${gatt.password}") + + gatt.ssid?.let { ssid -> + + gatt.password?.let { pass -> + + enableAp() + + controller.getWifiHelper().startWifiSearch(ssid, pass, this) + + } + } + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + + override fun onFirmware(firmware: String) {} + + override fun onModelId(modelID: Int) {} + + override fun onModelName(modelName: String) { + if ("HERO11 Black" !in modelName) { +// activity?.runOnUiThread { +// Toast.makeText( +// context, +// activity?.getString(R.string.go_pro_layout_black_11_not_detected), +// Toast.LENGTH_LONG +// ).show() +// } + } + } + + override fun onSerialNumber(serialNumber: String) {} + + override fun onSsid(wifiSSID: String) {} + + override fun disableAp() { + gatt.disableAp() + } + + override fun enableAp() { + gatt.enableAp() + } + + override fun shutterOff() { + gatt.shutterOff() + } + + override fun shutterOn() { + gatt.shutterOn() + } + + override fun onNetworkBound() { + + callbacks?.onConnected() + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt b/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt index af4538d93..d2315041f 100644 --- a/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt +++ b/app/src/main/java/com/fieldbook/tracker/interfaces/CollectController.kt @@ -4,8 +4,11 @@ import android.content.Context import android.location.Location import android.os.Handler import com.fieldbook.tracker.devices.camera.CanonApi +import com.fieldbook.tracker.devices.camera.GoProApi import com.fieldbook.tracker.devices.camera.UsbCameraApi import com.fieldbook.tracker.location.GPSTracker +import com.fieldbook.tracker.utilities.BluetoothHelper +import com.fieldbook.tracker.utilities.FfmpegHelper import com.fieldbook.tracker.utilities.GeoNavHelper import com.fieldbook.tracker.utilities.GnssThreadHelper import com.fieldbook.tracker.utilities.SoundHelperImpl @@ -15,6 +18,7 @@ import com.fieldbook.tracker.views.CollectInputView import com.fieldbook.tracker.views.RangeBoxView import com.fieldbook.tracker.views.TraitBoxView import com.serenegiant.widget.UVCCameraTextureView +import org.phenoapps.interfaces.security.SecureBluetooth import org.phenoapps.security.SecureBluetoothActivityImpl interface CollectController: FieldController { @@ -45,6 +49,10 @@ interface CollectController: FieldController { fun logNmeaMessage(nmea: String) fun getCanonApi(): CanonApi fun getWifiHelper(): WifiHelper + fun getBluetoothHelper(): BluetoothHelper fun getUsbApi(): UsbCameraApi fun getUvcView(): UVCCameraTextureView + fun getGoProApi(): GoProApi + fun advisor(): SecureBluetooth + fun getFfmpegHelper(): FfmpegHelper } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt index 6e7c424aa..e9a171e94 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/AbstractCameraTrait.kt @@ -18,6 +18,7 @@ import android.util.Log import android.widget.ImageView import android.widget.Toast import androidx.documentfile.provider.DocumentFile +import androidx.media3.ui.PlayerView import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.fieldbook.tracker.R @@ -48,13 +49,13 @@ abstract class AbstractCameraTrait : companion object { const val TAG = "Camera" - const val type = "canon" } protected var activity: Activity? = null protected var connectBtn: FloatingActionButton? = null protected var captureBtn: FloatingActionButton? = null protected var imageView: ImageView? = null + protected var styledPlayerView: PlayerView? = null protected var recyclerView: RecyclerView? = null protected val background = CoroutineScope(Dispatchers.IO) @@ -73,18 +74,15 @@ abstract class AbstractCameraTrait : } override fun setNaTraitsText() {} - override fun type(): String { - return type - } override fun loadLayout() { //slight delay to make navigation a bit faster - Handler(Looper.getMainLooper()).postDelayed({ + //Handler(Looper.getMainLooper()).postDelayed({ loadAdapterItems() - }, 500) + // }, 500) super.loadLayout() } @@ -94,6 +92,7 @@ abstract class AbstractCameraTrait : connectBtn = act.findViewById(R.id.camera_fragment_connect_btn) captureBtn = act.findViewById(R.id.camera_fragment_capture_btn) imageView = act.findViewById(R.id.trait_camera_iv) + styledPlayerView = act.findViewById(R.id.trait_camera_spv) recyclerView = act.findViewById(R.id.camera_fragment_rv) recyclerView?.adapter = ImageAdapter(this) @@ -102,7 +101,7 @@ abstract class AbstractCameraTrait : } - protected fun saveBitmapToStorage(bmp: Bitmap, obsUnit: RangeObject) { + protected fun saveBitmapToStorage(format: String, bmp: Bitmap, obsUnit: RangeObject) { val plot = obsUnit.plot_id val studyId = collectActivity.studyId @@ -123,16 +122,16 @@ abstract class AbstractCameraTrait : DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName)?.let { dir -> - val name = "${sanitizedTraitName}_${plot}_$time.png" + val name = "${sanitizedTraitName}_${plot}_$time.jpg" dir.createFile("*/*", name)?.let { file -> context.contentResolver.openOutputStream(file.uri)?.let { output -> - bmp.compress(Bitmap.CompressFormat.PNG, 100, output) + bmp.compress(Bitmap.CompressFormat.JPEG, 80, output) database.insertObservation( - plot, traitDbId, type, file.uri.toString(), + plot, traitDbId, format, file.uri.toString(), person, location, "", studyId, null, @@ -227,47 +226,49 @@ abstract class AbstractCameraTrait : private fun loadAdapterItems() { - val thumbnailModels = getImageObservations().mapNotNull { + background.launch { - var model: ImageAdapter.Model? = null + val thumbnailModels = getImageObservations().mapNotNull { - try { + var model: ImageAdapter.Model? = null - DocumentsContract.getDocumentThumbnail( - context.contentResolver, - Uri.parse(it.value), Point(256, 256), null - )?.let { bmp -> + try { - model = ImageAdapter.Model(it.value, bmp) + DocumentsContract.getDocumentThumbnail( + context.contentResolver, + Uri.parse(it.value), Point(256, 256), null + )?.let { bmp -> - } + model = ImageAdapter.Model(it.value, bmp) - } catch (f: FileNotFoundException) { + } - f.printStackTrace() + } catch (f: FileNotFoundException) { - model = null - } + f.printStackTrace() - model - } + model = null + } - activity?.runOnUiThread { + model + } - (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) + ui.launch { + (recyclerView?.adapter as? ImageAdapter)?.submitList(thumbnailModels) + + scrollToLast() + } } } private fun getImageObservations(): Array { - val traitDbId = collectActivity.traitDbId.toInt() + val traitDbId = collectActivity.traitDbId val plot = collectActivity.observationUnit val studyId = collectActivity.studyId - return database.getAllObservations(studyId).filter { - it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot - }.toTypedArray() + return database.getAllObservations(studyId, plot, traitDbId) } private fun deleteItem(model: ImageAdapter.Model) { diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt index 85012cf81..c2d3f3610 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/CameraTrait.kt @@ -3,6 +3,7 @@ package com.fieldbook.tracker.traits import android.content.Context import android.util.AttributeSet import dagger.hilt.android.AndroidEntryPoint +import java.lang.RuntimeException @AndroidEntryPoint open class CameraTrait : AbstractCameraTrait { @@ -14,5 +15,9 @@ open class CameraTrait : AbstractCameraTrait { attrs, defStyleAttr ) + + override fun type(): String { + throw RuntimeException() + } } diff --git a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt index a1f108de7..b1eda5cc0 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/CanonTrait.kt @@ -162,13 +162,13 @@ class CanonTrait : uiScope.launch(Dispatchers.Main) { - saveBitmapToStorage(bmp, obsUnit) + saveBitmapToStorage(type(), bmp, obsUnit) } } - override fun getSsidName() = - preferences.getString(GeneralKeys.CANON_SSID_NAME, "Canon") ?: "Canon" +// override fun getSsidName() = +// preferences.getString(GeneralKeys.CANON_SSID_NAME, "Canon") ?: "Canon" override fun onNetworkBound() { diff --git a/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt index 3420027a9..99351362b 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/GoProTraitLayout.kt @@ -1,81 +1,38 @@ package com.fieldbook.tracker.traits -import android.app.Activity import android.app.AlertDialog import android.bluetooth.BluetoothAdapter -import android.bluetooth.BluetoothDevice import android.content.Context import android.content.Intent import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.net.Uri -import android.os.Build import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.util.Log import android.view.View -import android.widget.ImageView import android.widget.ProgressBar -import android.widget.Toast -import androidx.core.graphics.scale -import androidx.documentfile.provider.DocumentFile -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.media3.common.util.UnstableApi import com.fieldbook.tracker.R -import com.fieldbook.tracker.activities.CollectActivity -import com.fieldbook.tracker.adapters.ImageTraitAdapter -import com.fieldbook.tracker.database.models.ObservationModel +import com.fieldbook.tracker.devices.camera.GoProApi import com.fieldbook.tracker.preferences.GeneralKeys -import com.fieldbook.tracker.utilities.DocumentTreeUtil -import com.fieldbook.tracker.utilities.ExifUtil -import com.fieldbook.tracker.utilities.FileUtil -import com.fieldbook.tracker.utilities.GoProWrapper -import com.google.android.exoplayer2.DefaultLoadControl -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.source.MediaSource -import com.google.android.exoplayer2.source.ProgressiveMediaSource -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector -import com.google.android.exoplayer2.trackselection.TrackSelector -import com.google.android.exoplayer2.ui.StyledPlayerView -import com.google.android.exoplayer2.upstream.DefaultDataSource -import com.google.android.material.floatingactionbutton.FloatingActionButton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.phenoapps.androidlibrary.Utils -import org.phenoapps.fragments.gopro.GoProGatt -import org.phenoapps.fragments.gopro.GoProGattInterface -import org.phenoapps.fragments.gopro.GoProHelper -import org.phenoapps.interfaces.gatt.GattCallbackInterface -import org.phenoapps.interfaces.security.SecureBluetooth - -//TODO close connenction on collect activity finsishes onDestroy -//TODO leave stream at top of layout -> capture overlayed on the preview bottom or corner -> gallery below the preview -> connect button at bottom -> hide everything if not connected other than gallery (DONE) +import org.phenoapps.fragments.gopro.GoProFragment +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + //todo gopro different versions ////todo aim for hero 11 -> if not 11 then say a message with a link to an email //TODO improve time between taking a picture and it showing in match list items with timestamp when doing media query -//TODO : add animated progress bar to dummy image after it is shuttered' (DONE) //TODO: shutter on the gopro detected in FB //TODO test usb camera trait with UVC. -//TODO: reopening preview after going back to config +@UnstableApi @AndroidEntryPoint class GoProTraitLayout : - BaseTraitLayout, - ImageTraitAdapter.ImageItemHandler, - GattCallbackInterface, - GoProGattInterface, - GoProGatt.GoProGattController, - GoProHelper.OnGoProStreamReady { - - //go pro specific collector interface - interface GoProCollector { - fun wrapper(): GoProWrapper - fun advisor(): SecureBluetooth - } + CameraTrait, + GoProApi.Callbacks { companion object { const val TAG = "GoProTrait" @@ -83,55 +40,7 @@ class GoProTraitLayout : private const val CAMERA_DELAY_MS = 10000L } - private var activity: Activity? = null - - //ui components - private lateinit var playerView: StyledPlayerView - private lateinit var imageRecyclerView: RecyclerView - - //buttons - private lateinit var connectButton: FloatingActionButton - private lateinit var shutterButton: FloatingActionButton - - //exoplayer instance - private var player: ExoPlayer? = null - private var streamStarted = false - - //collect activity controller - private lateinit var collector: GoProCollector - - private val scope by lazy { CoroutineScope(Dispatchers.IO) } - - private val wrapper by lazy { collector.wrapper() } - - private val helper by lazy { wrapper.helper } - - private val gatt by lazy { wrapper.gatt } - - private val playerListener: Player.Listener = object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - when (playbackState) { - Player.STATE_IDLE, Player.STATE_ENDED -> Log.d( - TAG, "Player Idle/Ended" - ) - Player.STATE_BUFFERING -> if (!streamStarted) { - Log.d(TAG, "Player Buffering") - Log.d(TAG, "Requesting start stream.") - streamStarted = true - helper?.requestStream() - } - - Player.STATE_READY -> { - Log.d(TAG, "Player Ready") - initializeCameraShutterButton() - initializeDisconnectButton() - playerView.visibility = View.VISIBLE - shutterButton.visibility = View.VISIBLE - } - } - } - } + private var dialogWaitForStream: AlertDialog? = null constructor(context: Context?) : super(context) constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) @@ -141,785 +50,203 @@ class GoProTraitLayout : defStyleAttr ) - override fun layoutId(): Int { - return R.layout.trait_go_pro - } - - override fun setNaTraitsText() {} override fun type(): String { return type } - private fun initWork(act: Activity) { - - collector = (act as GoProCollector) + private fun setup() { - playerView = act.findViewById(R.id.go_pro_pv) - connectButton = act.findViewById(R.id.go_pro_connect_btn) - shutterButton = act.findViewById(R.id.go_pro_capture_btn) - imageRecyclerView = act.findViewById(R.id.go_pro_rv) + setupWaitForStreamDialog() - activity = act + styledPlayerView?.visibility = View.VISIBLE + imageView?.visibility = View.INVISIBLE - //set control buttons gone - shutterButton.visibility = View.GONE + styledPlayerView?.layoutParams = ConstraintLayout.LayoutParams( + ConstraintLayout.LayoutParams.MATCH_CONSTRAINT, + ConstraintLayout.LayoutParams.WRAP_CONTENT + ).also { + it.startToStart = ConstraintLayout.LayoutParams.PARENT_ID + it.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID + it.topToBottom = recyclerView?.id ?: 0 + it.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID + } - //set player gone - playerView.visibility = View.GONE + val started = controller.getGoProApi().isStreamStarted() + Log.d(TAG, "Connected: $started") - //set images gone - //imageRecyclerView.visibility = View.GONE + if (started) { - connectButton.visibility = View.VISIBLE + createPlayer() - initializeConnectButton() - loadAdapterItems() + } else { - detectActiveConnection() + initializeConnectButton() + } } - override fun init(act: Activity) { + private fun setupWaitForStreamDialog() { - initWork(act) + dialogWaitForStream = AlertDialog.Builder(context) + .setTitle(R.string.dialog_go_pro_wait_stream_title) + .setMessage(R.string.dialog_go_pro_wait_stream_message) + .setPositiveButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() + } + .create() + dialogWaitForStream?.setView(ProgressBar(context).also { + it.isIndeterminate = true + it.layoutParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT + ) + it.layout(16, 16, 16, 16) + }) } - private fun detectActiveConnection() { - - createPlayer() - + override fun loadLayout() { + super.loadLayout() + setup() } private fun initializeConnectButton() { - //reset ui component states - player?.stop() - player?.release() - player?.clearMediaItems() - player?.clearVideoSurface() - player = null - playerView.player = null - - //buttons other than connect are gone until feature is available - shutterButton.visibility = View.GONE - - //reset global flags - this.streamStarted = false - - //start connection flow when button is pressed - connectButton.setOnClickListener { + connectBtn?.visibility = View.VISIBLE + captureBtn?.visibility = View.GONE + styledPlayerView?.visibility = View.GONE + connectBtn?.setOnClickListener { connect() } } - private fun initializeDisconnectButton() { + @UnstableApi private fun onShutter() { - connectButton.setOnClickListener { + captureBtn?.isEnabled = false - clearResources() - - initializeConnectButton() - } - } - - private fun awaitNetworkConnectionDialog() { - - if (activity?.window?.isActive == true) { - - var skipBssid = false - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - skipBssid = true - } - - if (skipBssid || gatt.bssid != null) { - - enableAp() - - gatt.ssid?.let { s -> - - gatt.password?.let { p -> - - val dialog = androidx.appcompat.app.AlertDialog.Builder( - context, - R.style.AppAlertDialog - ) - .setTitle(context.getString(R.string.trait_go_pro_await_ap_title)) - .setMessage(context.getString(R.string.trait_go_pro_await_ap_message, s, p)) - .setCancelable(true) - .setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - } - .setNeutralButton(context.getString(R.string.trait_go_pro_await_ap_neutral)) { _, _ -> - helper?.openWifiSettings() - } - .setOnDismissListener { - Log.d(TAG, "$s connection attempting...") - } - .create() - - dialog.setView(ProgressBar(context).also { - it.isIndeterminate = true - it.layoutParams = LayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ) - it.layout(16, 16, 16, 16) - }) - - dialog.show() - - helper?.connectToGoProWifi(dialog, s, p, gatt.bssid) { - - activity?.runOnUiThread { - - - helper?.requestStream() - - } - } - } - } - } - } - } - - private fun onShutter() { - - shutterButton.isEnabled = false - - val plot = currentRange.plot_id + //val plot = currentRange.plot_id val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() - val traitName = currentTrait.name - val traitFormat = type - val traitDbId = currentTrait.id - val time = Utils.getDateTime() - val name = "${traitName}_${plot}_$time.png" - - //load data here - val data = hashMapOf( - "studyId" to studyId, - "plot" to plot, - "traitName" to traitName, - "traitFormat" to traitFormat, - "traitDbId" to traitDbId, - "name" to name - ) - - saveDummyObservation(data) + //val traitName = currentTrait.name + val timestamp = SimpleDateFormat("yyyy-MM-dd-hh-mm-ss", Locale.US) + .format(Calendar.getInstance().time) + //val name = "${traitName}_${plot}_$timestamp.png" Handler(Looper.getMainLooper()).postDelayed({ - shutterButton.isEnabled = true - - helper?.queryMedia(data) - + controller.getGoProApi().queryMedia( + GoProApi.ImageRequestData( + studyId, + currentRange, + currentTrait, + timestamp + ) + ) }, CAMERA_DELAY_MS) } private fun initializeCameraShutterButton() { - shutterButton.visibility = View.VISIBLE - - shutterButton.setOnClickListener { + connectBtn?.visibility = View.GONE + styledPlayerView?.visibility = View.VISIBLE - shutterOn() - - onShutter() - } - } + captureBtn?.visibility = View.VISIBLE - override fun onExit() { - super.onExit() - player?.stop() - player?.release() - player = null - } + (captureBtn?.layoutParams as ConstraintLayout.LayoutParams) + .bottomToBottom = styledPlayerView?.id ?: 0 - private fun createPlayer() { + captureBtn?.setOnClickListener { - context?.let { ctx -> + captureBtn?.isEnabled = false - val ffmpegOutputUri = "udp://0.0.0.0:8555" + controller.getGoProApi().shutterOn() - //Max. Buffer: The maximum duration, in milliseconds, of the media the player is attempting to buffer. Once the buffer reaches Max Buffer, it will stop filling it up. - //min Buffer: The minimum length of media that the player will ensure is buffered at all times, in milliseconds. - //Playback Buffer: The default amount of time, in milliseconds, of media that needs to be buffered in order for playback to start or resume after a user action such as a seek. - //Buffer for playback after rebuffer: The duration of the media that needs to be buffered in order for playback to continue after a rebuffer, in milliseconds. - - val loadControl: DefaultLoadControl = DefaultLoadControl.Builder() - .setPrioritizeTimeOverSizeThresholds(true) - .setBufferDurationsMs(500, 1000, 500, 500) - .build() - - val trackSelector: TrackSelector = DefaultTrackSelector(ctx) - val mediaSource: MediaSource = - ProgressiveMediaSource.Factory(DefaultDataSource.Factory(ctx)).createMediaSource( - MediaItem.fromUri( - Uri.parse(ffmpegOutputUri) - ) - ) - - player?.stop() - player?.release() - player = null - - player = ExoPlayer.Builder(ctx) - .setTrackSelector(trackSelector) - .setLoadControl(loadControl) - .build() - - playerView.player = player.also { - it?.addListener(playerListener) - it?.setMediaSource(mediaSource) - it?.playWhenReady = true - it?.prepare() - } - - playerView.requestFocus() - - Log.i(TAG, "Player created") - } - } - - private var credentialsDialog: AlertDialog? = null - - private fun awaitCredentialsDialog() { - - if (activity?.window?.isActive == true) { - - if (credentialsDialog?.isShowing == true) credentialsDialog?.dismiss() - - credentialsDialog = AlertDialog.Builder( - context, - R.style.AppAlertDialog - ) - .setTitle(context.getString(R.string.trait_go_pro_await_ble_title)) - .setMessage(context.getString(R.string.trait_go_pro_await_ble_message)) - .setCancelable(true) - .setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.dismiss() - } - .create() - - credentialsDialog?.setView(ProgressBar(context).also { - it.isIndeterminate = true - it.layoutParams = LayoutParams( - LayoutParams.WRAP_CONTENT, - LayoutParams.WRAP_CONTENT - ) - it.layout(16, 16, 16, 16) - }) - - credentialsDialog?.show() + onShutter() } } - private fun connect() { + override fun onImageRequestReady(bitmap: Bitmap, data: GoProApi.ImageRequestData) { - //ensure bluetooth is enabled - collector.advisor().withNearby { adapter -> + ui.launch { - if (helper?.isBluetoothEnabled(adapter) != true) { + saveBitmapToStorage(type(), bitmap, data.range) - //if not enabled, start intent for settings - context?.startActivity( - Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) - ) + captureBtn?.isEnabled = true - } else { - - helper?.registerReceivers() - - activity?.let { act -> - - //start discovering and find a go pro device - adapter.startDiscovery() - - fun onSelected(device: BluetoothDevice?) { - - if (device != null) { - - helper?.connectToGoPro( - device, - wrapper.gatt.callback - ) - - adapter.cancelDiscovery() - - //show progress bar dialog until credentials are established - awaitCredentialsDialog() - } - } - - val dialog = androidx.appcompat.app.AlertDialog.Builder( - context, - R.style.AppAlertDialog - ) - .setTitle(context.getString(R.string.trait_go_pro_await_device_title)) - .setCancelable(true) - .setSingleChoiceItems( - helper?.goProDevices?.map { it.name }?.distinct()?.toTypedArray() ?: arrayOf(), - -1 - ) { dialog, which -> - onSelected(helper?.goProDevices?.get(which)) - dialog.dismiss() - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> - onSelected(null) - dialog.dismiss() - } - .setOnDismissListener { - helper?.bluetoothSearchJob?.cancel() - } - .create() - - dialog.show() - - helper?.searchForBluetoothGoPro(act, dialog) - } - } } } - private fun scrollToLast() { - - try { - - imageRecyclerView.postDelayed({ - - val pos = imageRecyclerView.adapter?.itemCount ?: 1 - - imageRecyclerView.scrollToPosition(pos - 1) - - }, 500L) - - } catch (e: Exception) { - - e.printStackTrace() - + override fun onStreamRequested() { + ui.launch { + styledPlayerView?.player = controller.getGoProApi().createPlayer() + styledPlayerView?.requestFocus() } } - override fun loadLayout() { - - //slight delay to make navigation a bit faster - Handler(Looper.getMainLooper()).postDelayed({ - - loadAdapterItems() - - }, 500) - - super.loadLayout() - } - - override fun deleteTraitListener() { - - if (!isLocked) { - - (imageRecyclerView.layoutManager as? LinearLayoutManager) - ?.findFirstCompletelyVisibleItemPosition()?.let { index -> - - if (index > -1) { - - (imageRecyclerView.adapter as? ImageTraitAdapter) - ?.currentList?.get(index)?.let { model -> - - showDeleteImageDialog(model) - - } - } - } - } + override fun onStreamReady() { + dialogWaitForStream?.dismiss() + initializeCameraShutterButton() + captureBtn?.visibility = View.VISIBLE } - private fun showDeleteImageDialog(model: ImageTraitAdapter.Model) { - - if (!isLocked) { - - context.contentResolver.openInputStream(Uri.parse(model.uri)).use { input -> - - val imageView = ImageView(context) - - val bmp = BitmapFactory.decodeStream(input) - - val scaled = bmp.scale(512, 512, true) - - imageView.setImageBitmap(scaled) - - AlertDialog.Builder(context, R.style.AppAlertDialog) - .setTitle(R.string.trait_go_pro_camera_delete_photo_title) - .setOnCancelListener { dialog -> dialog.dismiss() } - .setPositiveButton(android.R.string.ok) { dialog, _ -> - - dialog.dismiss() + private fun createPlayer() { - deleteItem(model) + styledPlayerView?.player = controller.getGoProApi().createPlayer() - } - .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } - .setView(imageView) - .show() - } - } + Log.i(GoProFragment.TAG, "Player created") } - private fun saveDummyObservation(data: Map) { - - scope.launch { - - withContext(Dispatchers.IO) { + private fun connect() { - //get current trait's trait name, use it as a plot_media directory + controller.advisor().withNearby { adapter -> - val plot = data["plot"] - val studyId = data["studyId"] - val name = data["name"] + if (!adapter.isEnabled) { - database.insertObservation( - plot, - data["traitDbId"], - data["traitFormat"], - name, - prefs.getString(GeneralKeys.FIRST_NAME, "") + " " - + prefs.getString(GeneralKeys.LAST_NAME, ""), - (activity as? CollectActivity)?.locationByPreferences, - "", - studyId, - null, - null, - null + //if not enabled, start intent for settings + context?.startActivity( + Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) ) - activity?.runOnUiThread { - - loadAdapterItems() - - scrollToLast() - } - } - } - } - - private fun saveBitmapToStorage(bmp: Bitmap, data: Map) { - - scope.launch { - - withContext(Dispatchers.IO) { - - //get current trait's trait name, use it as a plot_media directory - currentTrait.name?.let { traitName -> - - val traitDbId = currentTrait.id - - val sanitizedTraitName = FileUtil.sanitizeFileName(traitName) - - DocumentTreeUtil.getFieldMediaDirectory(context, sanitizedTraitName) - ?.let { usbPhotosDir -> - - val plot = data["plot"] - - val studyId = data["studyId"] ?: String() - - val name = data["name"] ?: String() - - val timestamp = Utils.getDateTime() - - usbPhotosDir.createFile("*/*", name)?.let { file -> - - context.contentResolver.openOutputStream(file.uri)?.let { output -> - - bmp.compress(Bitmap.CompressFormat.PNG, 100, output) - - database.deleteTraitByValue(studyId, plot, traitDbId, name) - - database.insertObservation( - plot, - data["traitDbId"], - data["traitFormat"], - file.uri.toString(), - (activity as? CollectActivity)?.person, - (activity as? CollectActivity)?.locationByPreferences, - "", - studyId, - null, - null, - null - ) - - //if sdk > 24, can write exif information to the image - //goal is to encode observation variable model into the user comments - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - - ExifUtil.saveVariableUnitModelToExif( - context, - (controller.getContext() as CollectActivity).person, - timestamp, - database.getStudyById(studyId), - database.getObservationUnitById(currentRange.plot_id), - database.getObservationVariableById(currentTrait.id), - file.uri - ) - - } - - activity?.runOnUiThread { - - loadAdapterItems() - - scrollToLast() - } - } - } - } - } - } - } - } - - private fun loadAdapterItems() { - - Log.d(TAG, "loadAdapterItems") - - val studyId = (context as CollectActivity).studyId - - imageRecyclerView.adapter = ImageTraitAdapter(context, this, hasProgressBar = true) - - currentTrait.name?.let { traitName -> - - try { - - val traitDbId = currentTrait.id - - val plot = currentRange.plot_id - val toc = System.currentTimeMillis() - val uris = database.getAllObservations(studyId, plot, traitDbId) - val tic = System.currentTimeMillis() - - Log.d(TAG, "Photo trait query time ${uris.size} photos: ${(tic - toc) * 1e-3}") - - val models = - uris.mapIndexed { index, model -> ImageTraitAdapter.Model(model.value, index) } - - activity?.runOnUiThread { - if (models.isNotEmpty()) { - imageRecyclerView.visibility = View.VISIBLE - (imageRecyclerView.adapter as ImageTraitAdapter).submitList(models) - imageRecyclerView.adapter?.notifyItemRangeChanged(0, models.size) - } else imageRecyclerView.visibility = View.GONE - } - - } catch (e: Exception) { - - e.printStackTrace() - - } - } - } - - private fun getImageObservations(): Array { - - val traitDbId = collectActivity.traitDbId.toInt() - val plot = collectActivity.observationUnit - val studyId = collectActivity.studyId - - return database.getAllObservations(studyId).filter { - it.observation_variable_db_id == traitDbId && it.observation_unit_id == plot - }.toTypedArray() - } - - private fun deleteItem(model: ImageTraitAdapter.Model) { - - val studyId = prefs.getInt(GeneralKeys.SELECTED_FIELD_ID, 0).toString() - - //get current trait's trait name, use it as a plot_media directory - currentTrait?.name?.let { traitName -> - - val plot = currentRange.plot_id - - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> - - try { - - DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> - - val result = image.delete() - - if (result) { - - database.deleteTraitByValue( - studyId, - plot, - currentTrait.id, - image.uri.toString() - ) - - loadAdapterItems() - - } else { - - collectActivity.runOnUiThread { - - Toast.makeText(context, R.string.photo_failed_to_delete, Toast.LENGTH_SHORT).show() - - } - } - } - - } catch (e: Exception) { + } else { - Log.e(TAG, "Failed to delete images.", e) + connectToBluetoothDevice(adapter) - } } } } - override fun refreshLock() { - super.refreshLock() - (context as CollectActivity).traitLockData() - } - - override fun onApRequested() {} - - override fun onBoardType(boardType: String) {} - - override fun onBssid(wifiBSSID: String) {} - - /** - * Collect activity callback region - */ - override fun onCredentialsAcquired() { - - try { - - Log.d(TAG, "onCredentialsAcquired") - - credentialsDialog?.dismiss() - - awaitNetworkConnectionDialog() - - } catch (e: Exception) { - - e.printStackTrace() - - } - } - - override fun onFirmware(firmware: String) {} - - override fun onModelId(modelID: Int) {} + private fun connectToBluetoothDevice(adapter: BluetoothAdapter) { - override fun onModelName(modelName: String) { - if ("HERO11 Black" !in modelName) { - activity?.runOnUiThread { - Toast.makeText(context, - activity?.getString(R.string.go_pro_layout_black_11_not_detected), - Toast.LENGTH_LONG).show() + val devices = adapter.bondedDevices.toTypedArray() + val displayList = devices.map { it.name }.toTypedArray() + var selected = 0 + val dialog = AlertDialog.Builder(context, R.style.AppAlertDialog) + .setTitle(R.string.trait_go_pro_await_device_title) + .setCancelable(true) + .setSingleChoiceItems(displayList, 0) { _, which -> + selected = which } - } - } - - override fun onSerialNumber(serialNumber: String) {} - - override fun onSsid(wifiSSID: String) {} - - override fun onStreamReady() { - - try { - - Log.d(TAG, "onStreamReady") - - createPlayer() - - } catch (e: Exception) { - - e.printStackTrace() - - } - } - - override fun onImageRequestReady(bitmap: Bitmap, data: Map) { - - try { - - saveBitmapToStorage(bitmap, data) - - } catch (e: Exception) { - - e.printStackTrace() - - } - } - - override fun onItemClicked(model: ImageTraitAdapter.Model) { - - if (!isLocked) { - - getImageObservations().firstOrNull { it.value == model.uri }?.let { observation -> - - DocumentFile.fromSingleUri(context, Uri.parse(observation.value))?.let { image -> - - activity?.startActivity(Intent(Intent.ACTION_VIEW, image.uri).also { - it.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - }) - } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + dialog.dismiss() } - } - } - - override fun onItemDeleted(model: ImageTraitAdapter.Model) { - - try { + .setPositiveButton(android.R.string.ok) { dialog, which -> + Log.d(TAG, which.toString()) + controller.getGoProApi().onConnect(devices[selected], this) + }.create() - showDeleteImageDialog(model) - - } catch (e: Exception) { - - e.printStackTrace() - - } + dialog.show() } - private fun stopAp() { - - helper?.stopStream() + override fun onConnected() { - playerView.player?.stop() - playerView.player?.release() + controller.getGoProApi().requestStream() - disableAp() } - private fun clearResources() { + override fun onInitializeGatt() { - stopAp() - - activity?.runOnUiThread { - if (player != null) { - player!!.stop() - player!!.release() - } + ui.launch { + dialogWaitForStream?.show() } - - gatt.clear() - } - - - override fun disableAp() { - gatt.disableAp() - } - - override fun enableAp() { - gatt.enableAp() - } - - override fun shutterOff() { - gatt.shutterOff() - } - - override fun shutterOn() { - gatt.shutterOn() } } \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt b/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt index 080ffd6a6..2017be645 100644 --- a/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt +++ b/app/src/main/java/com/fieldbook/tracker/traits/UsbCameraTraitLayout.kt @@ -160,7 +160,7 @@ class UsbCameraTraitLayout : CameraTrait, UsbCameraApi.Callbacks { lastBitmap?.let { bmp -> - saveBitmapToStorage(bmp, currentRange) + saveBitmapToStorage(type(), bmp, currentRange) activity?.runOnUiThread { diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/BluetoothHelper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/BluetoothHelper.kt new file mode 100644 index 000000000..53f18c4a7 --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/utilities/BluetoothHelper.kt @@ -0,0 +1,95 @@ +package com.fieldbook.tracker.utilities + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.WifiManager +import android.net.wifi.WifiNetworkSpecifier +import android.os.Build +import android.os.PatternMatcher +import android.util.Log +import androidx.annotation.RequiresApi +import dagger.hilt.android.qualifiers.ApplicationContext +import org.phenoapps.fragments.gopro.GoProHelper +import javax.inject.Inject + +class BluetoothHelper @Inject constructor(@ApplicationContext private val context: Context) { + + companion object { + const val TAG = "BluetoothHelper" + } + + interface BluetoothRequester { + fun onStart() + fun onStop() + fun onPause() + fun onDestroy() + } + + var goProDevices: ArrayList = ArrayList() + + // Create a BroadcastReceiver for ACTION_FOUND. bluetooth devices + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + BluetoothDevice.ACTION_FOUND -> { + // Discovery has found a device. Get the BluetoothDevice + // object and its info from the Intent. + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + ?.let { device -> + + if (device.name != null) { + + Log.d(GoProHelper.TAG, device.name) + + if (GoProHelper.GoProDeviceIdentifier in device.name) goProDevices.add(device) + } + } + } + } + } + } + + private fun unregister(receiver: BroadcastReceiver) { + try { + context.unregisterReceiver(receiver) + } catch (e: Exception) { + e.printStackTrace() + } + } + +// override fun isBluetoothEnabled(adapter: BluetoothAdapter): Boolean { +// return adapter.isEnabled +// } + + private fun registerReceivers() { + context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND)) + } + + fun onStart() { + registerReceivers() + } + + fun onPause() { + + } + + fun onStop() { + + } + + fun onResume() { + + } + + fun onDestroy() { + unregister(receiver) + } +} diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/FfmpegHelper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/FfmpegHelper.kt new file mode 100644 index 000000000..25d6b851b --- /dev/null +++ b/app/src/main/java/com/fieldbook/tracker/utilities/FfmpegHelper.kt @@ -0,0 +1,142 @@ +package com.fieldbook.tracker.utilities + +import android.util.Log +import com.arthenica.ffmpegkit.FFmpegKit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.phenoapps.fragments.gopro.GoProHelper +import java.lang.Exception +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import javax.inject.Inject + +class FfmpegHelper @Inject constructor() { + + private val scope = MainScope() + + private var ffmpegJob: Job? = null + private var keepAliveJob: Job? = null + + private var udpSocket: DatagramSocket? = null + + fun cancel() { + + ffmpegJob?.cancel() + + keepAliveJob?.cancel() + + udpSocket?.disconnect() + + udpSocket?.close() + + FFmpegKit.cancel() + } + + /** + * starts a background thread to send keep alive messages + */ + fun initRequestTimer() { + + startFfmpegCommand() + + val keepStreamAliveData = "_GPHD_:1:0:2:0.000000\n".toByteArray() + + try { + + val inetAddress = InetAddress.getByName("10.5.5.9") + + try { + + udpSocket?.disconnect() + + if (udpSocket == null) { + udpSocket = DatagramSocket().also { + it.reuseAddress = true + it.soTimeout = GoProHelper.UDP_SOCKET_TIMEOUT.toInt() + } + } + + udpSocket?.bind(InetSocketAddress(8554)) + + } catch (ignore: Exception) { + } + + keepAliveJob?.cancel() + + keepAliveJob = scope.launch { + + withContext(Dispatchers.IO) { + + while (true) { + + try { + + val keepStreamAlivePacket = DatagramPacket( + keepStreamAliveData, + keepStreamAliveData.size, + inetAddress, + 8554 + ) + + udpSocket?.send(keepStreamAlivePacket) + + Log.i(GoProHelper.TAG, "Keep Alive sent") + + } catch (e: Exception) { + + e.printStackTrace() + + } + + delay(GoProHelper.KEEP_ALIVE_MESSAGE_PACKET_DELAY) + } + } + } + + Log.i(GoProHelper.TAG, "requestTimer init successfully") + + } catch (e: Exception) { + + e.printStackTrace() + + } + } + + fun stop() { + + ffmpegJob?.cancel() + + FFmpegKit.cancel() + } + + /** + * Starts FFMPEG background coroutine that creates udp substream for Android/Exoplayer to interpret. + */ + private fun startFfmpegCommand() { + + stop() + + scope.launch { + + withContext(Dispatchers.IO) { + + val streamInputUri = "udp://:8554" // maybe different depending on gopro modelID? + + val command = + "-fflags nobuffer -flags low_delay -f:v mpegts -an -probesize 100000 -i $streamInputUri -f mpegts -vcodec copy udp://localhost:8555?pkt_size=1316" // -probesize 100000 is minimum for Hero 10 + + Log.d(GoProHelper.TAG, "Executing FFMPEG Kit: $command") + + FFmpegKit.execute(command) + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/GoProWrapper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/GoProWrapper.kt deleted file mode 100644 index 4bf3c48cd..000000000 --- a/app/src/main/java/com/fieldbook/tracker/utilities/GoProWrapper.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.fieldbook.tracker.utilities - -import android.content.Context -import android.graphics.Bitmap -import com.fieldbook.tracker.activities.CollectActivity -import com.fieldbook.tracker.traits.BaseTraitLayout -import com.fieldbook.tracker.traits.GoProTraitLayout -import dagger.hilt.android.qualifiers.ActivityContext -import org.phenoapps.fragments.gopro.GoProGatt -import org.phenoapps.fragments.gopro.GoProHelper -import javax.inject.Inject - -class GoProWrapper @Inject constructor( - @ActivityContext private val context: Context -) : - GoProGatt.GoProGattController, - GoProHelper.OnGoProStreamReady { - - private val activity: CollectActivity by lazy { context as CollectActivity } - - var helper: GoProHelper? = null - - val gatt: GoProGatt by lazy { GoProGatt(this) } - - fun attach() { - //activity.advisor().connectWith { - helper = GoProHelper(activity, this) - //} - } - - fun destroy() { - try { - helper?.onDestroy() - gatt.clear() - } catch (e: Exception) { - e.printStackTrace() - } - } - - override fun onApRequested() { - //not used - } - - override fun onCredentialsAcquired() { - - if (helper != null) { - if (helper?.checkWifiEnabled() != true) { - helper?.enableWifi() - } - } - - activity.runOnUiThread { - val goProTrait: BaseTraitLayout = - activity.getTraitLayouts().getTraitLayout(GoProTraitLayout.type) - if (goProTrait is GoProTraitLayout) { - goProTrait.onCredentialsAcquired() - } - } - } - - override fun onImageRequestReady(bitmap: Bitmap, data: Map) { - activity.runOnUiThread { - val goProTrait: BaseTraitLayout = - activity.getTraitLayouts().getTraitLayout(GoProTraitLayout.type) - if (goProTrait is GoProTraitLayout) { - goProTrait.onImageRequestReady(bitmap, data) - } - } - } - - override fun onStreamReady() { - activity.runOnUiThread { - val goProTrait: BaseTraitLayout = - activity.getTraitLayouts().getTraitLayout(GoProTraitLayout.type) - if (goProTrait is GoProTraitLayout) { - goProTrait.onStreamReady() - } - } - } - - override fun onModelName(modelName: String) { - activity.runOnUiThread { - val goProTrait: BaseTraitLayout = - activity.getTraitLayouts().getTraitLayout(GoProTraitLayout.type) - if (goProTrait is GoProTraitLayout) { - goProTrait.onModelName(modelName) - } - } - } - - override fun onBoardType(boardType: String) {} - override fun onBssid(wifiBSSID: String) {} - override fun onFirmware(firmware: String) {} - override fun onModelId(modelID: Int) {} - override fun onSerialNumber(serialNumber: String) {} - override fun onSsid(wifiSSID: String) {} - -} \ No newline at end of file diff --git a/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt b/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt index 81c7126a3..16449b127 100644 --- a/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt +++ b/app/src/main/java/com/fieldbook/tracker/utilities/WifiHelper.kt @@ -8,7 +8,6 @@ import android.net.NetworkRequest import android.net.wifi.WifiNetworkSpecifier import android.os.Build import android.os.PatternMatcher -import androidx.annotation.RequiresApi import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -19,7 +18,6 @@ class WifiHelper @Inject constructor(@ApplicationContext private val context: Co } interface WifiRequester { - fun getSsidName(): String fun onNetworkBound() } @@ -31,20 +29,22 @@ class WifiHelper @Inject constructor(@ApplicationContext private val context: Co private val networkCallback = object : ConnectivityManager.NetworkCallback() { - @RequiresApi(Build.VERSION_CODES.M) override fun onAvailable(network: Network) { super.onAvailable(network) - val bound = connectivityManager.bindProcessToNetwork(network) - - requester?.onNetworkBound() - + val bound = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.bindProcessToNetwork(network) + requester?.onNetworkBound() + } else { + TODO("VERSION.SDK_INT < M") + } } - @RequiresApi(Build.VERSION_CODES.M) override fun onLost(network: Network) { super.onLost(network) - connectivityManager.bindProcessToNetwork(null) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + connectivityManager.bindProcessToNetwork(null) + } } } @@ -52,12 +52,48 @@ class WifiHelper @Inject constructor(@ApplicationContext private val context: Co if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - connectivityManager.bindProcessToNetwork(null) + try { + + connectivityManager.bindProcessToNetwork(null) + + connectivityManager.unregisterNetworkCallback(networkCallback) + + } catch (e: Exception) { + + e.printStackTrace() - connectivityManager.unregisterNetworkCallback(networkCallback) + } } } + fun startWifiSearch(ssid: String, password: String, requester: WifiRequester) { + + this.requester = requester + + val specifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + WifiNetworkSpecifier.Builder() + .setSsid(ssid) + .setWpa2Passphrase(password) + .build() + } else { + TODO("VERSION.SDK_INT < Q") + } + + val request = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .removeCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .setNetworkSpecifier(specifier) + .build() + + //connectivityManager.registerNetworkCallback(request, networkCallback) + + connectivityManager.requestNetwork( + request, + networkCallback + ) + } + fun startWifiSearch(format: String, requester: WifiRequester) { this.requester = requester diff --git a/app/src/main/res/layout/trait_camera.xml b/app/src/main/res/layout/trait_camera.xml index c5d992215..efc1f5fc1 100644 --- a/app/src/main/res/layout/trait_camera.xml +++ b/app/src/main/res/layout/trait_camera.xml @@ -44,6 +44,23 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> + + Starts a device connection. This previews the camera\'s display. Capture a photo + Waiting for live preview + This may take a minute. \ No newline at end of file