Sibainu Relax Room

柴犬と過ごす

Android CameraX を始めてみて2

朝の散歩で久ぶりに合った女の子の柴犬です。相手の柴犬が元気すぎてわが柴犬は気遅れ気味ですが、元気に遊べたのでこの後満足そうに帰途につきました。

国府宮神社の儺追神事(はだか祭)の準備も進んでいるようです。今年は2月22日に行われます。

概要

スマホで撮った写真をアプリから取り出すにはどうしたらいいのか、ずっと考えています。

output.savedUri のから何とか実所在フォルダーらしきものが取得できましたので記録します。

次に紹介する本はちょっと過激な表紙ですが、本の内容はまじめてよく理解できる書き方をしています。先の「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

copy

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

copy

<?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)

copy

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

copy

<?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

copy

<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>

今回はここまでとします。