Android CameraX を始めてみて2
朝の散歩で久ぶりに合った女の子の柴犬です。相手の柴犬が元気すぎてわが柴犬は気遅れ気味ですが、元気に遊べたのでこの後満足そうに帰途につきました。
国府宮神社の儺追神事(はだか祭)の準備も進んでいるようです。今年は2月22日に行われます。
概要
スマホで撮った写真をアプリから取り出すにはどうしたらいいのか、ずっと考えています。
output.savedUri のから何とか実所在フォルダーらしきものが取得できましたので記録します。
WINGSプロジェクト 齊藤 新三(著), 山田 祥寛(監修)
2023年10月26日現在
次に紹介する本はちょっと過激な表紙ですが、本の内容はまじめてよく理解できる書き方をしています。先の「Androidアプリ開発」で飛ばしているところを丁寧に説明しているので、これで理解が早まりました。お勧めです。
しかもこれが100円で買え、ボリュームがすごい量です。なので著者に感謝です。
URI から File を取得
写真を撮影するとき、キャプチャーに成功した時呼ばれる MainActivity.kt の onImageSaved コーブバック関数の中に output.savedUri で URI が取得できるようです。
これを使って File オブジェクトが作成できないか試してみました。
いろいろ調べてみたところ、データベースに接続して URI を検索して取得するようです。
で次のコードを挿入してみました。
//projection は null でいい場合もありますがこの場合は必要なようです
val projection = arrayOf(MediaStore.MediaColumns.DATA)
//アプリケーションのデータベースに接続して URI を検索します
val cursor = applicationContext.contentResolver
.query(Uri.parse(output.savedUri.toString()),
projection,
null,
null,
null)
//カーソルがあれば
if (cursor != null) {
var path: String? = null
//最初のレコードにカーソルを移します
if (cursor.moveToFirst()) {
//path はカラムのインデックスが 0 のようです
path = cursor.getString(0)
//スマホのテキストビューにパスを表示します(左の画像)
findViewById<TextView>(R.id.tvinfo).text = path
//スマホのテキストビューにパスを表示します(右の画像)
//msg = "Photo capture succeeded: ${output.savedUri}"
findViewById<TextView>(R.id.tvinfo).text = msg
}
cursor.close()
if (path != null) {
//パスが取得できたので File を作成します
val file = File(path)
}
}
左が上のコードを使って実行してみたところ、実在するファイル名らしきものが取得できました。右が、output.savedUri の内容で URI を表しているようです。
MainActivity.kt
package siba.inu.android.cameraxnas import android.Manifest import android.annotation.SuppressLint import android.content.ContentValues import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.util.Log import android.widget.Button import android.widget.TextView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.video.FallbackStrategy import androidx.camera.video.MediaStoreOutputOptions import androidx.camera.video.Quality import androidx.camera.video.QualitySelector import androidx.camera.video.Recorder import androidx.camera.video.Recording import androidx.camera.video.VideoCapture import androidx.camera.video.VideoRecordEvent import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker import jcifs.context.BaseContext import jcifs.smb.NtlmPasswordAuthenticator import jcifs.smb.SmbFile import kotlinx.coroutines.* import org.apache.commons.net.ftp.FTP import org.apache.commons.net.ftp.FTPClient import siba.inu.android.cameraxnas.databinding.ActivityMainBinding import java.io.BufferedInputStream import java.io.File import java.nio.ByteBuffer import java.text.SimpleDateFormat import java.util.Locale import java.util.Properties import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.stream.Stream typealias LumaListener = (luma: Double) -> Unit class MainActivity : AppCompatActivity() { private lateinit var viewBinding: ActivityMainBinding private var imageCapture: ImageCapture? = null private var videoCapture: VideoCapture<Recorder>? = null private var recording: Recording? = null private lateinit var cameraExecutor: ExecutorService // コルーチン関連 private val mf_job: Job = SupervisorJob() private val mf_scope = CoroutineScope(Dispatchers.Default + this.mf_job) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewBinding = ActivityMainBinding.inflate(layoutInflater) setContentView(viewBinding.root) // Request camera permissions if (allPermissionsGranted()) { startCamera() } else { ActivityCompat.requestPermissions( this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS) } // Set up the listeners for take photo and video capture buttons viewBinding.imageCaptureButton.setOnClickListener { takePhoto() } viewBinding.videoCaptureButton.setOnClickListener { captureVideo() } cameraExecutor = Executors.newSingleThreadExecutor() initstart() } private fun takePhoto() { // Get a stable reference of the modifiable image capture use case val imageCapture = imageCapture ?: return // Create time stamped name and MediaStore entry. val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US) .format(System.currentTimeMillis()) val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image") } } // Create output options object which contains file + metadata val outputOptions = ImageCapture.OutputFileOptions .Builder(contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) .build() // Set up image capture listener, which is triggered after photo has // been taken imageCapture.takePicture( outputOptions, ContextCompat.getMainExecutor(this), object : ImageCapture.OnImageSavedCallback { override fun onError(exc: ImageCaptureException) { Log.e(TAG, "Photo capture failed: ${exc.message}", exc) } @SuppressLint("SuspiciousIndentation") override fun onImageSaved(output: ImageCapture.OutputFileResults){ val msg = "Photo capture succeeded: ${output.savedUri}" Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show() Log.d(TAG, msg) val projection = arrayOf(MediaStore.MediaColumns.DATA) //アプリケーションのデータベースに接続して URI を検索します val cursor = applicationContext .contentResolver .query(Uri.parse(output.savedUri.toString()), projection, null, null, null) if (cursor != null) { var path: String? = null //最初のレコードにカーソルを移します if (cursor.moveToFirst()) { //path はカラムのインデックスが 0 のようです path = cursor.getString(0) //スマホのテキストビューにパスを表示します findViewById<TextView>(R.id.tvinfo).text = path } cursor.close() if (path != null) { //パスが取得できたので File を作成します val file = File(path) } } } } ) } // Implements VideoCapture use case, including start and stop capturing. private fun captureVideo() { val videoCapture = this.videoCapture ?: return viewBinding.videoCaptureButton.isEnabled = false val curRecording = recording if (curRecording != null) { // Stop the current recording session. curRecording.stop() recording = null return } // create and start a new recording session val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US) .format(System.currentTimeMillis()) val contentValues = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, name) put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4") if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX-Video") } } val mediaStoreOutputOptions = MediaStoreOutputOptions .Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) .setContentValues(contentValues) .build() recording = videoCapture.output .prepareRecording(this, mediaStoreOutputOptions) .apply { if (PermissionChecker.checkSelfPermission(this@MainActivity, Manifest.permission.RECORD_AUDIO) == PermissionChecker.PERMISSION_GRANTED) { withAudioEnabled() } } .start(ContextCompat.getMainExecutor(this)) { recordEvent -> when(recordEvent) { is VideoRecordEvent.Start -> { viewBinding.videoCaptureButton.apply { text = getString(R.string.stop_capture) isEnabled = true } } is VideoRecordEvent.Finalize -> { if (!recordEvent.hasError()) { val msg = "Video capture succeeded: " + "${recordEvent.outputResults.outputUri}" Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT) .show() Log.d(TAG, msg) } else { recording?.close() recording = null Log.e(TAG, "Video capture ends with error: " + "${recordEvent.error}") } viewBinding.videoCaptureButton.apply { text = getString(R.string.start_capture) isEnabled = true } } } } } private fun startCamera() { val cameraProviderFuture = ProcessCameraProvider.getInstance(this) cameraProviderFuture.addListener({ // Used to bind the lifecycle of cameras to the lifecycle owner val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() // Preview val preview = Preview.Builder() .build() .also { it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider) } val recorder = Recorder.Builder() .setQualitySelector(QualitySelector.from(Quality.HIGHEST, FallbackStrategy.higherQualityOrLowerThan(Quality.SD))) .build() videoCapture = VideoCapture.withOutput(recorder) imageCapture = ImageCapture.Builder().build() /* val imageAnalyzer = ImageAnalysis.Builder().build() .also { setAnalyzer( cameraExecutor, LuminosityAnalyzer { luma -> Log.d(TAG, "Average luminosity: $luma") } ) } */ // Select back camera as a default val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA try { // Unbind use cases before rebinding cameraProvider.unbindAll() // Bind use cases to camera cameraProvider.bindToLifecycle( this, cameraSelector, preview, imageCapture, videoCapture) } catch(exc: Exception) { Log.e(TAG, "Use case binding failed", exc) } }, ContextCompat.getMainExecutor(this)) } private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { ContextCompat.checkSelfPermission( baseContext, it) == PackageManager.PERMISSION_GRANTED } override fun onDestroy() { super.onDestroy() cameraExecutor.shutdown() } companion object { private const val TAG = "CameraXApp" private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" private const val REQUEST_CODE_PERMISSIONS = 10 private val REQUIRED_PERMISSIONS = mutableListOf ( Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO ).apply { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { add(Manifest.permission.WRITE_EXTERNAL_STORAGE) } }.toTypedArray() } private fun initstart() { findViewById<Button>(R.id.btLeft).setOnClickListener() { // 非同期処理 mf_scope.launch { //smbroot は "sm://192.168.□□.□□/□□□□□□□□□□□□□□/" SmbAccess("sora", "Ryu#0220#Ryu", "192.168.0.5", "sm://192.168.0.5/photo/JIJISora/") } } findViewById<Button>(R.id.btRight).setOnClickListener() { findViewById<TextView>(R.id.tvinfo).text = "FTP" // 非同期処理 mf_scope.launch { //ftpDirectory は "ftp://192.168.□□.□□/" 不要 FtpAccess("sora", "Ryu#0220#Ryu", "192.168.0.5", "photo/JIJISora/") } } } private fun SmbAccess(user: String, password: String, domain: String, smbroot: String) { val prop = Properties() prop.setProperty("jcifs.smb.client.minVersion", "SMB202") prop.setProperty("jcifs.smb.client.maxVersion", "SMB311") val bc = BaseContext(jcifs.config.PropertyConfiguration(prop)) val auth = NtlmPasswordAuthenticator(domain, user, password) val cifsCon = bc.withCredentials(auth) try { val sf = SmbFile(smbroot, cifsCon) Log.d("ServerFileAccess", sf.server) Log.d("ServerFileAccess", sf.share) Log.d("ServerFileAccess", sf.name) Log.d("ServerFileAccess", sf.path) if (sf.exists()) { val filenames: Array<String> = sf.list() for (i in filenames.indices) { Log.d("ServerFileAccess",filenames[i]) } Log.d("ServerFileAccess", "ファイル有") } else { Log.d("ServerFileAccess", "ファイル無") } }catch (ex: Exception) { Log.d("ServerFileAccess", ex.toString()) } finally { Log.d("ServerFileAccess", "finally") } findViewById<TextView>(R.id.tvinfo).text = "SmbAccess" } // private fun FtpAccess(ftpUsername: String, ftpPassword: String, ftpServer: String, ftpDirectory: String) : String { var infolist = mutableListOf<String>() val ftpClient = FTPClient() try { Log.d("ServerFileAccess", "1") //デフォルト ポートでリモート ホストに接続され、システムに割り当てられたポートで現在のホストから発信されるソケットを開きます ftpClient.connect(ftpServer) Log.d("ServerFileAccess", "2") //指定されたユーザーとパスワードを使用して FTP サーバーにログインします ftpClient.login(ftpUsername, ftpPassword) Log.d("ServerFileAccess", "3") //データ転送を行うために接続するデータ ポートを開くようにサーバーに指示されます ftpClient.enterLocalPassiveMode() Log.d("ServerFileAccess", "4") //多くの FTP サーバーはデフォルトで BINARY になっています ftpClient.setFileType(FTP.BINARY_FILE_TYPE) Log.d("ServerFileAccess", "5") Log.d("ServerFileAccess", ftpClient.status) infolist.add(ftpClient.status.toString()) Log.d("ServerFileAccess", ftpClient.toString()) infolist.add(ftpClient.toString()) //ディレクトリ移動 ftpClient.changeWorkingDirectory(ftpDirectory) Log.d("ServerFileAccess", "6") val filenames: Array<String> = ftpClient.listNames() Log.d("ServerFileAccess", filenames.toString()) for (i in filenames.indices) { Log.d("ServerFileAccess", filenames[i]) infolist.add(filenames[i]) } //NASにファイルをアップロード //val inputStream = FileInputStream(file) //val fileName = file.name //ftpClient.storeFile(fileName, inputStream) //inputStream.close() //ftpClient.logout() //ftpClient.disconnect() } catch (e: Exception) { e.printStackTrace() infolist.add(e.printStackTrace().toString()) } finally { Log.d("ServerFileAccess", "finally") ftpClient.logout() ftpClient.disconnect() //findViewById<TextView>(R.id.tvinfo).text = "FtpAccess" } return infolist.joinToString("\n") } } private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer { private fun ByteBuffer.toByteArray(): ByteArray { rewind() // Rewind the buffer to zero val data = ByteArray(remaining()) get(data) // Copy the buffer into a byte array return data // Return the byte array } override fun analyze(image: ImageProxy) { val buffer = image.planes[0].buffer val data = buffer.toByteArray() val pixels = data.map { it.toInt() and 0xFF } val luma = pixels.average() listener(luma) image.close() } }
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-feature android:name="android.hardware.camera.any" /> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <!-- uses-permission android:name="android.permission.ADD_SYSTEM_SERVICE"/ --> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.CameraxNas" tools:targetApi="31"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>
build.gradle.kts(Module :app)
plugins { id("com.android.application") id("org.jetbrains.kotlin.android") } android { namespace = "siba.inu.android.cameraxnas" compileSdk = 34 defaultConfig { applicationId = "siba.inu.android.cameraxnas" minSdk = 26 targetSdk = 33 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" } buildFeatures { viewBinding = true } } dependencies { implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.6.1") implementation("com.google.android.material:material:1.11.0") implementation("androidx.constraintlayout:constraintlayout:2.1.4") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") // Apache クライアント implementation(files("libs/commons-net-3.10.0.jar")) // Java implementation(files("libs/bcprov-jdk15on-1.70.jar")) // implementation(files("libs/slf4j-api-2.0.11.jar")) implementation("org.slf4j:slf4j-api:1.8.0-beta2") runtimeOnly("org.jlib:jlib-awslambda-logback:1.0.0") // SMB //https://mvnrepository.com/artifact/eu.agno3.jcifs/jcifs-ng/2.1.7 //implementation(files("libs/jcifs-ng-2.1.10.jar")) implementation(files("libs/jcifs-ng-2.1.7.jar")) // コルーチン val kotlin_version = "1.3.9" implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${kotlin_version}") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:${kotlin_version}") //implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0-RC2") val lifecycle_version = "2.7.0" implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:${lifecycle_version}") implementation("androidx.lifecycle:lifecycle-runtime-ktx:${lifecycle_version}") // camerax1 val camerax_version = "1.1.0-beta01" implementation("androidx.camera:camera-core:${camerax_version}") implementation("androidx.camera:camera-camera2:${camerax_version}") implementation("androidx.camera:camera-lifecycle:${camerax_version}") implementation("androidx.camera:camera-video:${camerax_version}") implementation("androidx.camera:camera-view:${camerax_version}") implementation("androidx.camera:camera-extensions:${camerax_version}") }
layout / activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <androidx.camera.view.PreviewView android:id="@+id/viewFinder" android:layout_marginTop="50dp" android:layout_width="match_parent" android:layout_height="390dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> </androidx.camera.view.PreviewView> <Button android:id="@+id/image_capture_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="10dp" android:layout_marginEnd="30dp" android:elevation="2dp" android:text="@string/take_photo" app:layout_constraintEnd_toStartOf="@id/vertical_centerline" app:layout_constraintTop_toBottomOf="@+id/viewFinder" /> <androidx.constraintlayout.widget.Guideline android:id="@+id/vertical_centerline" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintGuide_percent=".50" /> <ScrollView android:layout_width="match_parent" android:layout_height="0px" android:layout_marginTop="65dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/viewFinder" > <TextView android:id="@+id/tvinfo" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="TextView"/> </ScrollView> <Button android:id="@+id/video_capture_button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="30dp" android:layout_marginTop="10dp" android:elevation="2dp" android:text="@string/start_capture" app:layout_constraintStart_toStartOf="@+id/vertical_centerline" app:layout_constraintTop_toBottomOf="@+id/viewFinder" /> <Button android:id="@+id/btLeft" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:text="@string/bt_left" app:layout_constraintEnd_toStartOf="@+id/vertical_centerline" app:layout_constraintHorizontal_bias="0.73" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Button android:id="@+id/btRight" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:text="@string/bt_right" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.263" app:layout_constraintStart_toStartOf="@+id/vertical_centerline" app:layout_constraintTop_toTopOf="parent" /> </androidx.constraintlayout.widget.ConstraintLayout>
values / strings.xml
<resources> <string name="app_name">CameraXApp</string> <string name="take_photo">Take Photo</string> <string name="start_capture">Start Capture</string> <string name="stop_capture">Stop Capture</string> <string name="bt_left">SMB</string> <string name="bt_right">FTP</string> </resources>
今回はここまでとします。