androidのテストを書きたい

テストのコードラボやドキュメントを読みながらテストの書き方を勉強して自分で作成したアプリにテストコードを導入してみた。
ただコードラボは書き方が変わっている部分があったり、使っているライブラリなどによって躓いたこともあったので、調べたことや参考にした記事などまとめていく。
Advanced Android in Kotlin 05.1: Testing Basics  |  Android Developers
Advanced Android in Kotlin 05.2: Introduction to Test Doubles and Dependency Injection  |  Android Developers
Advanced Android in Kotlin 05.3: Testing Coroutines and Jetpack integrations  |  Android Developers


LiveData

InstantTaskExecutorRule()を追加する

アーキテクチャコンポーネント(AC)関連のテストの時に追加する。
ACがbackground executorをテスト用に変換して、テスト結果が同期的に順番に得られるようにする。

 testImplementation "android.arch.core:core-testing:1.1.0"
@get:Rule
val rule: TestRule = InstantTaskExecutorRule()

ObserverForever()で値を監視する

これを使うことでlifecyclerOwnerなしでliveDataの監視ができる。

val observer = Observer<Int>{}

try {
    sampleViewModel.data.observeForever(observer)
    ...
}finally {
    sampleViewModel.data.removeObserver(observer)
}

参考
Advanced Android in Kotlin 05.1: Testing Basics  |  Android Developers
InstantTaskExecutorRule  |  Android Developers
LiveDataのUnitTest. ちょっとやっかいな部分もあったので、LiveDataのテストをどう書くのかまとめ… | by Kenji Abe | Medium
以下の記事はliveDataのobserverの設定と解除、値の取得をUtilでまとめる方法
Unit-testing LiveData and other common observability problems | by Jose Alcérreca | Android Developers | Medium


Coroutine

testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"

runTest

コルーチンを含むテスト全体をrunTestでラップする。

suspend fun fetchData(): String {
    delay(1000L)
    return "Hello world"
}

@Test
fun dataShouldBeHelloWorld() = runTest {
    val data = fetchData()
    assertEquals("Hello world", data)
}

メインディスパッチャの変更

UnitTestではローカル JVM 上で実行されるため、Android UI スレッドをラップする Mainディスパッチャは使用できず、テスト対象のコードがメインスレッドを参照すると、単体テスト中に例外がスローされる。
そのためMainディスパッチャをTestDispatchersに置き換える必要がある。
この操作は各UnitTestで重複するため、テストルールとして抽出しておく。

class MainDispatcherRule(
    val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(testDispatcher)
    }

    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

class HomeViewModelTestUsingRule {
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun settingMainDispatcher() = runTest {
        ...
    }
}

TestDispatchers

新しく開始されるコルーチンのスケジューリング方法が異なる2つのTestDispatchersがある。

  • StandardTestDispatcher

テストスレッドが空いているときにコルーチンを実行する。
テスト中の新規コルーチンの動作を細かく制御でき、本番環境のコードにおけるコルーチンのスケジューリングに似ているため、同時実行を重視してみる時に使用。

  • UnconfinedTestDispatcher

新しいコルーチンが開始されると、現在のスレッドで積極的に開始する。
コルーチンを使用した単純なテストをするだけならこっちで良い。

参考
Testing Kotlin coroutines on Android  |  Android Developers


Hilt

androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44'

@HiltAndroidTestとHiltAndroidRuleを追加する

Hilt を使用する UI テストには @HiltAndroidTest アノテーションをつけHilt コンポーネントを生成させる。
テストクラスに HiltAndroidRuleを追加するが、他にも追加するテストルールがある時はルールを1つにまとめるか、HiltAndroidRuleを最初に実行する。

@get:Rule
  var rule = RuleChain.outerRule(HiltAndroidRule(this)).
        around(SettingsActivityTestRule(...))
@get:Rule(order = 0)
var HiltAndroidRule = HiltAndroidRule(this)

@get:Rule(order = 1)
var settingsActivityTestRule = SettingsActivityTestRule()

Applicationを設定する

インストゥルメンテーションテストの場合は新しいテストランナーを作成してGradleファイルでテストランナーを設定する。 (Robolectricを使ったテストの時はまた違った書き方だけどやってみてないので省略)

class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}
android {
    defaultConfig {
        testInstrumentationRunner "com.example.android.dagger.CustomTestRunner"  //作成したテストランナーのclass path
    }
}

テストに型を注入する

@Injectをつけた型を注入するよう、hiltRule.inject()を実行する。

@Inject
lateinit var analyticsAdapter: AnalyticsAdapter

@Before
fun init() {
    hiltRule.inject()
}

本番環境とは違うモックインスタンスを注入したい場合は、擬似依存関係を定義した新しいHiltモジュールを作成して、上記と同様に注入する。

@Module
@TestInstallIn(
    components = [SingletonComponent::class],  
    replaces = [AnalyticsModule::class]  //置き換えたい本番用のHiltモジュールを指定する
)
abstract class FakeAnalyticsModule {

  @Singleton
  @Binds
  abstract fun bindAnalyticsService(fakeAnalyticsService: FakeAnalyticsService): AnalyticsService 
  // FakeAnalyticsServiceを代わりに使うようにバインディング
}

これでHiltを使ったテスト全てでモックのインスタンスに置き換わる。 単一のテストだけ置き換えたい時は、テストに@UninstallModulesアノテーションをつけてモジュールを使わないように設定し、そのテスト内で新しくモジュールを定義する。

@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {

  @Module
  @InstallIn(SingletonComponent::class)
  abstract class TestModule {

    @Singleton
    @Binds
    abstract fun bindAnalyticsService(
      fakeAnalyticsService: FakeAnalyticsService
    ): AnalyticsService
  }

※余談
実行時「error: cannot find symbol import dagger.hilt.android.internal.testing.root.DaggerDefault_HiltComponents_SingletonC;」が出た。
(これと同じエラーHilt, androidTest build failed in versions >= 2.40.4 · Issue #3162 · google/dagger · GitHub) 依存関係まわりを色々変更してみようと試行錯誤していたけど、自分の場合はFakeで作ったクラス(上のコードでいうFakeAnalyticsService)に@Inject constructor() をつけ忘れてただけだった。

参考
Hilt testing guide  |  Android Developers

〈kotlin〉 WorkManagerで指定時間後に通知を送信

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

ボタンを押したら指定時間経ったあと通知を送信する機能をWorkManagerを使って実装。

WorkManager

非同期で行う1回限りのタスクや定期的に実行したいタスクをバックグラウンドで処理するAPI
アプリ アーキテクチャ: データレイヤ - WorkManager でタスクのスケジュールを設定する - デベロッパー向け Android  |  Android デベロッパー  |  Android Developers

サンプルアプリ

JetpackComposeで作成。
TextFieldに入力された数値を指定時間として、ボタンを押すと指定時間経過した後で「◯分経過しました」と通知が来る。

Build.gradle

依存関係を追加する。

implementation "androidx.work:work-runtime-ktx:$work_version"

実行したい作業を定義

Workerクラスを使用。
doWork()メソッドに書かれた処理が、WorkManagerから提供されるバックグラウンドスレッドで非同期的に実行される。
doWork()メソッドは作業が成功したか失敗したか、再試行するかを示すResult型を返す。

const val MINUTE = "minute"

class NotificationWorker(context: Context, params: WorkerParameters):Worker(context, params) {

    private val notificationId = 1

    override fun doWork(): Result {
        return try {
            //WorkManagerに入力されたデータを受け取る
            val inputBreakTime = inputData.getLong(MINUTE, 0)

            val intent = Intent(applicationContext, MainActivity::class.java).apply {
                flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
            }

            val pendingIntent = PendingIntent
                .getActivity(applicationContext, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)

            val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
                .setSmallIcon(R.drawable.ic_launcher_foreground)
                .setContentTitle("Notification")
                .setContentText(applicationContext.getString(R.string.notification_description, inputBreakTime.toInt()))
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .setContentIntent(pendingIntent)
                .setAutoCancel(true)

            with(NotificationManagerCompat.from(applicationContext)){
                notify(notificationId, builder.build())
            }

            Result.success()
        }catch (e:Exception){
            Log.d("Notification", "failure : $e")
            Result.failure()
        }
    }
}

WorkRequestを作成する

Workerで定義した処理が実行されるようにスケジュール設定をする。

OneTimeWorkRequest : 1回だけ実行する
PeriodicWorkRequest : 定期的に繰り返す
今回は1回だけ通知を送るためOneTimeWorkRequestを作成する。

class MainViewModel(application: Application) :ViewModel() {

    private val workManager = WorkManager.getInstance(application)

    fun createBreakTimeNotification(minute:Long){
        val inputData = workDataOf(
            MINUTE to minute,
        )

        val workRequest = OneTimeWorkRequestBuilder<NotificationWorker>()
            .setInitialDelay(minute, TimeUnit.MINUTE)  //指定した時間遅延して実行する指定
            .setInputData(inputData)  //Workerに渡すデータを入力する
            .build()
       
        //WorkManagerにリクエストを送信
        workManager.enqueue(workRequest)
    }

    class MainViewModelFactory(private val application: Application) : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
                @Suppress("UNCHECKED_CAST")
                return MainViewModel(application) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }
    }
}

データの入出力は Key-ValueペアのData オブジェクトで行う。
詳細は以下。
WorkManager に関する高度なトピック  |  Android デベロッパー  |  Android Developers
タスクについて優先度や実行時の条件(電池残量、ネットワーク状態など)も追加できる。
WorkRequest の定義  |  Android デベロッパー  |  Android Developers

ViewModelProvider.Factoryでハマった。

WorkManagerのリクエストをViewModel内で作成するために、ViewModelのコンストラクタにapplicationを追加し、Factoryを作成した。でもコンパイルエラー…。

inheritance from an interface with '@jvmdefault' members is only allowed with -xjvm-default option

gradleに以下を追加したらコンパイルできるようになったが、よく分かってないのでまた勉強したい。

android {
  kotlinOptions {
    freeCompilerArgs +=  "-Xjvm-default=all"
  }
}

Implementing ViewModelProvider.Factory fails with Inheritance from an interface with '@JvmDefault' after adding Android Jetpack Compose navigation lib - Stack Overflow
Google Issue Tracker

〈kotlin〉 Roomでサポートされていない型を保存する(TypeConverter)

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

Roomについて。
mtnmr.hatenablog.com

Roomはプリミティブ型(Int, String, Booleanとか基本の型)の保存をサポートしているが、
それ以外の型でもTypeConverterを使えば保存できそうだったのでやってみる。

Room を使用して複雑なデータを参照する  |  Android デベロッパー  |  Android Developers
公式ドキュメントを参考に、Date型を保存できるようにする。
レイアウトにEditText, Button, RecyclerViewを用意し、
Buttonを押すとEditTextに入力した単語とその日時をRoomに保存、
保存したデータはRecyclerViewに一覧表示されるサンプルアプリを作って試した。

Entity

@Entity
data class Item(
    @PrimaryKey(autoGenerate = true) val id:Int = 0,
    val ItemText: String,
    val createdAt: Date?
)

TypeConverter

Roomが永続化できる既知の型とカスタム型との間での変換方法を Room に伝えるメソッドを定義。
この例では、
・Date型 → Roomに保存できるLong型への変換
・Long型 → Date型へ戻す変換
の2つのメソッドを作成している。

class DateConverter {
    @TypeConverter
    fun fromTimeStamp(value: Long?): Date? {
        return value?.let { Date(it) }
    }

    @TypeConverter
    fun dateToTimeStamp(date:Date?): Long?{
        return date?.time?.toLong()
    }
}

RoomDatabase

Databaseには@TypeConvertersアノテーションを追加して、
定義したコンバータクラスをRoomが認識できるように登録する。

@Database(entities = [Item::class], version = 1)
@TypeConverters(DateConverter::class)
abstract class AppDatabase : RoomDatabase() {
  abstract fun itemDao(): itemDao
}

あとはViewModel内とかで取得したDate型をそのまま保存するだけ。

fun saveItem(text:String){
    viewModelScope.launch {
        if(text != ""){
           itemDao.saveItem(Item(ItemText = text, createdAt = Date()))
        }
    }
}

TypeConverterに依存関係を渡したいとき

RoomがTypeConverterのインスタンス化をしているが、
TypeConverterに引数として依存関係を渡す場合はインスタンス化できない。
そのため自分でTypeConverterの初期化を制御する。

//クラスにアノテーションをつける
@ProvidedTypeConverter
class ExampleConverter(hoge: Hoge) {
  @TypeConverter
  fun StringToExample(string: String?): ExampleType? {
    ...
  }

  @TypeConverter
  fun ExampleToString(example: ExampleType?): String? {
    ...
  }
}
//Hogeを渡したインスタンスを作成
val exampleConverterInstance = ExampleConverter(Hoge())
//RoomDatabaseビルダーにコンバータクラスのインスタンスを渡す。
val db = Room.databaseBuilder(...)
  .addTypeConverter(exampleConverterInstance)
  .build()

以下の記事がわかりやすくて参考にした。
Room - Provided Type Converters explained - DEV Community 👩‍💻👨‍💻

Jetpack Composeのレイアウト関係メモ

Jetpack Composeを勉強していくなかで、「これ何だったっけ?」と何回も調べ直してることをメモしていく。随時更新。

Imageコンポーザブルのカスタマイズ

Customize an image  |  Jetpack Compose  |  Android Developers
contentScale
画像を表示したい枠に合うように、画像を切り抜いたり大きさを変更したりする。

contentScale 画像の変化
Fit 画像が境界線と合うように拡大・縮小
Crop 空いているスペースがなくなるように画像を切り抜き(アスペクト比そのまま)
FillBounds 空いているスペースがなくなるように画像を切り抜き(アスペクト比無視)

clip
画像の形を決める。
例えばModifier.clip(CircleShape)で円形、Modifier.clip(RoundedCornerShape(16.dp)で角丸。

Image(
    painter = painterResource(id = R.drawable.sample),
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(200.dp).clip(RoundedCornerShape(16.dp))
)

LazyColumn/Rowのアイテムの間隔

リストとグリッド  |  Jetpack Compose  |  Android Developers
Arrangement.spacedBy()
アイテム同士の間隔を空ける。
contentPadding
アイテム表示領域の上下左右に間隔を空ける。
(縦スクロールだったら、最初のアイテムの上、最後のアイテムの下、全てのアイテムの左右のpadding)

LazyColumn(
    contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
    verticalArrangement = Arrangement.spacedBy(4.dp),
)

Scroll 修飾子

操作  |  Jetpack Compose  |  Android Developers
コンテンツの境界が最大サイズ制約より大きい場合に、
ユーザーが要素をスクロールできるようにverticalScrollもしくはhorizontalScrollを設定する。
例えば端末を横向きにしたときColumnで配置したコンテンツが画面の表示領域に収まらない場合、スクロールできないと困る。

Column(
     modifier = Modifier.verticalScroll(rememberScrollState())
) 

ScrollState により、スクロール位置を変更したり現在の状態を取得したりする。

AnimatedVisibility

アニメーション  |  Jetpack Compose  |  Android Developers
コンテンツの表示・非表示をアニメーションにする。
表示・非表示をbeelean型のStateか、MutableTransitionStateで保存する。

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}
val state = remember {
    MutableTransitionState(false).apply {
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }
    Text(...)
}

Jetpack Composeを使ってみたい⑤ Navigation

Jetpack Composeを使ってみたい!と思ってCodelabを元に勉強してみた記録。
Jetpack Compose for Android Developers

JetpackComposeでNavigationコンポーネントを使って画面遷移を実装する。

準備

アプリレベルのbuild.gradleに依存関係を追加。

dependencies {
    def nav_version = "2.5.2"
    implementation "androidx.navigation:navigation-compose:$nav_version"
}

基本的なNavigation

アプリ内の画面を構成するコンポーザブルのバックスタックと各画面の状態を追跡するNavControllerを作成する。
他のコンポーザブルから参照できるよう上位のコンポーザブルに定義する。

val navController = rememberNavController()

ナビゲーショングラフを関連付けるためのコンテナであるNavHostをNavControllerに関連付ける。
NavHostにはNavControllerと最初の画面になるコンポーザブルのルートを渡す。
(ルートはコンポーザブルへのパスを定義するStringで、コンポーザブルごとに一意)
ナビゲーショングラフには目的地となるコンポーザブルを指定する。

NavHost(navController = navController, startDestination = "profile") {
    composable(route = "profile") { Profile(/*...*/) }
    composable(route = "friendslist") { FriendsList(/*...*/) }
    /*...*/
}

//目的地になるコンポーザブルは別で作成
@Composable
fun Profile(){
}
@Composable
fun FriendList(){
}

目的地に移動するときは各コンポーザブルに設定したルートを使用する。

navController.navigate("friendslist")

引数を使用したNavigation

コンポーザブルに引数を渡したい時は、ルートにプレースホルダーを追加する。
引数のタイプはデフォルトで文字列になっているが、argumentsを使用して明確に指定できる。
引数の値は、composable() 関数のラムダで使用可能なNavBackStackEntry から抽出する。

composable(
    "profile/{userId}",
    arguments = listOf(navArgument("userId") { type = NavType.StringType })
){ backStackEntry ->
    Profile(backStackEntry.arguments?.getString("userId"))
}

//引数を受け取るコンポーザブル
@Composable
fun FriendList(userId: String){
}

サンプルアプリ

HomeScreen ⇄ NextScreen の2つのScreenを遷移するアプリ。
HomeScreenはTextFieldに入力された文字を引数としてNextScreenに渡し、
NextScreenは受け取った文字列を表示する。

画面を管理するenum class
enum class NavigationScreen(){
    Home,
    Next
}
@Composable
fun NavigationApp() {
    val navController = rememberNavController()

    val backStackEntry by navController.currentBackStackEntryAsState()

    Scaffold(
        topBar = {
            //この後アプリバーを作成
            MyAppBar(
                canNavigateBack = backStackEntry?.destination?.route != NavigationScreen.Home.name,
                onClick = { navController.popBackStack() }
            )
        }
    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = NavigationScreen.Home.name,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable(NavigationScreen.Home.name) {
                HomeScreen(
                    //NavContorollerを直接渡さず、コールバックで渡すのが良さそう
                    onClick = { navController.navigate("${NavigationScreen.Next.name}/$it") }
                )
            }
            composable(
                "${NavigationScreen.Next.name}/{text}",
                arguments = listOf(navArgument("text") { type = NavType.StringType })
            ) {
                NextScreen(it.arguments?.getString("text").toString())
            }
        }
    }
}
アプリバー
@Composable
fun MyAppBar(
    canNavigateBack: Boolean,
    onClick:() -> Unit
){

    //currentDestinationがHomeScreenじゃなければアプリバーに戻るボタンを表示
    val navigationIcon: (@Composable () -> Unit)? = {
        if (canNavigateBack) {
            IconButton(onClick = onClick) {
                Icon(
                    imageVector = Icons.Outlined.ArrowBack,
                    contentDescription = "Back"
                )
            }
        } else {
            null
        }
    }

    TopAppBar(
        title = { Text(text = stringResource(id = R.string.app_name)) },
        navigationIcon = navigationIcon
    )
}
HomeScreen
@Composable
fun HomeScreen(
    onClick: (String) -> Unit
) {
    var name by remember { mutableStateOf("") }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Welcome Home!")

        Spacer(modifier = Modifier.padding(16.dp))

        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = {
                Text(text = "Enter your name")
            },
            singleLine = true
        )
        Button(
            onClick = { onClick(name) }
        ) {
            Text(text = "Next")
        }
    }
}
NextScreen
@Composable
fun NextScreen(text: String) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(8.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = "Hello, $text")
    }
}


参考
Jetpack Compose Navigation  |  Android Developers
Compose を使用したナビゲーション  |  Jetpack Compose  |  Android Developers


前の記事
mtnmr.hatenablog.com

AndroidでCloud Storage を使ってみる

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

写真や動画などを保管、提供することができるFirebase Cloud Storageを使ってみた。
プロジェクトは、
・ダウンロードボタンを押すとCloudStorageに保存された画像を取得してImageViewに表示
・アップロードボタンを押すとデバイス内のフォルダへ遷移し、写真を選択するとCloudStorageへアップロード
という簡単な構成で作った。

準備

これをもとにfirebaseをプロジェクトに追加する。
Android プロジェクトに Firebase を追加する  |  Android 向け Firebase

Firebaseコンソールを開き、Cloud StorageのRulesを設定する。
通常はFirebase Authenticationで認証されたユーザーのみにアクセス権をあげるのが良さそう。
Cloud Storage 用の Firebase セキュリティ ルールを理解する  |  Firebase Storage

service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write: if request.auth != null;
    }
  }
}

今回は自分で使ってみるだけなので、認証なしでアクセスできるようルールを変更。

rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /{allPaths=**} {
      allow read, write:if true;
    }
  }
}

また画像のダウンロードを試すため、CloudStorageのFilesにimageフォルダを作成し、
その中に"sample.jpeg"という名前で画像を追加しておいた。

モジュールのbuild.gradleにCloud Storageの依存関係を追加する。

dependencies {
    //Firebase Android BoM(部品構成表)を使用すると、アプリは常に互換性のあるバージョンのライブラリを使用する
    implementation platform('com.google.firebase:firebase-bom:30.3.1')
    implementation 'com.google.firebase:firebase-storage-ktx'
}

アップロードを試すためにデバイス内のフォルダにアクセスする必要があるため、
AndroidManifest内にパーミッションを追加。

 <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

activity_main.xml

ImageViewとButton2つのレイアウトを作成。

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

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:gravity="center">

        <ImageView
            android:id="@+id/download_image"
            android:layout_width="300dp"
            android:layout_height="300dp"
            android:src="@drawable/ic_launcher_foreground"
            android:background="@color/cardview_dark_background"/>

        <Button
            android:id="@+id/download_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="8dp"
            android:text="Download Image"/>

        <Button
            android:id="@+id/upload_button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_margin="8dp"
            android:text="Upload Image"/>
    </LinearLayout>

</androidx.constraintlayout.widget.ConstraintLayout>

CloudStorageの参照

//ストレージのインスタンスを取得
val storage = Firebase.storage
//referenceを取得
var storageRef = storage.reference

//child()を使って下位のフォルダやファイルも参照できる
var imageRef: StorageReference? = storageRef.child("image")
var sampleRef = storageRef.child("image/sample.jpeg")

ダウンロード

メモリにダウンロード

アプリが持つメモリより大きいファイルだとクラッシュしてしまうため、最大サイズを指定する。

val ONE_MEGABYTE: Long = 1024 * 1024
sampleRef.getBytes(ONE_MEGABYTE).addOnSuccessListener {
    val bitmap =  BitmapFactory.decodeByteArray(it, 0,  it.size)
    findViewById<ImageView>(R.id.download_image).setImageBitmap(bitmap) 
    Log.d("cloud storage", "download success")
}.addOnFailureListener {
    Log.d("cloud storage", "download failure $it")
}
ローカルファイルにダウンロード
val localFile = File.createTempFile("images", "jpg")
sampleRef.getFile(localFile).addOnSuccessListener {
   val bitmap = BitmapFactory.decodeFile(localFile.absolutePath)
   findViewById<ImageView>(R.id.download_image).setImageBitmap(bitmap)
   Log.d("cloud storage", "download ${localFile.absolutePath}")
}.addOnFailureListener {
   Log.d("cloud storage", "download failure $it")
}

アップロード

val stream = contentResolver.openInputStream(uri) //デバイス内の画像のURIからstreamに変換

val storageRef = storage.reference
val userImageRef = storageRef.child("image/${uri.path}") //参照を作成して、画像の保存先にする
val uploadTask = userImageRef.putStream(stream!!) //上記の参照にstreamを追加
uploadTask.addOnSuccessListener {
    Log.d("cloud storage", "upload success")
    stream.close()
}.addOnFailureListener { error ->
    Log.d("cloud storage", "upload failure $error")
}

MainActivity.kt全体コード

あらかじめアップロードしておいた画像がダウンロードされImageViewに表示、
選択した画像がCloud StorageのImageフォルダ内に保存されていることを確認した。

class MainActivity : AppCompatActivity() {

    private lateinit var storage:FirebaseStorage

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        storage = Firebase.storage

        val downloadButton = findViewById<Button>(R.id.download_button)
        downloadButton.setOnClickListener {
            downloadImage()
        }

        val uploadButton = findViewById<Button>(R.id.upload_button)
        uploadButton.setOnClickListener {
            checkOpenDocumentPermission()
        }
    }

    //Cloud Storageからダウンロード
    private fun downloadImage(){
        val storageRef = storage.reference
        val pathReference = storageRef.child("image/sample.jpeg")

        val ONE_MEGABYTE: Long = 1024 * 1024
        pathReference.getBytes(ONE_MEGABYTE).addOnSuccessListener {
            val bitmap =  BitmapFactory.decodeByteArray(it, 0,  it.size)
            findViewById<ImageView>(R.id.download_image).setImageBitmap(bitmap)
            Log.d("cloud storage", "download success")
        }.addOnFailureListener {
            Log.d("cloud storage", "download failure $it")
        }
    }
 
    //デバイス内ストレージのアクセス権
    private fun checkOpenDocumentPermission(){
        if(ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED){
            selectPhoto()
        }else{
            openDocumentLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
        }
    }

    private val openDocumentLauncher =
        registerForActivityResult(ActivityResultContracts.RequestPermission()){ isGranted:Boolean ->
            if(isGranted){
                selectPhoto()
            }else{
                Toast.makeText(this, "デバイス内の写真やメディアへのアクセスが許可されませんでした。", Toast.LENGTH_SHORT).show()
            }
        }

    //デバイスから画像を選ぶ
    private fun selectPhoto() {
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "image/*"
        }
        selectPhotoLauncher.launch(intent)
    }

    private val selectPhotoLauncher =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()){ result ->
            if(result.resultCode != RESULT_OK){
                return@registerForActivityResult
            }else{
                try {
                    var bitmap: Bitmap?
                    result.data?.data.also { uri ->
                        bitmap =
                            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P){
                                val source = ImageDecoder.createSource(contentResolver, uri!!)
                                ImageDecoder.decodeBitmap(source)
                            } else {
                                MediaStore.Images.Media.getBitmap(contentResolver, uri)
                            }
                        findViewById<ImageView>(R.id.download_image).setImageBitmap(bitmap)

                        if(uri != null) uploadImage(uri)
                    }
                }catch (e:Exception){
                    Log.d("cloud storage", e.toString())
                    Toast.makeText(this, "select photo Error", Toast.LENGTH_SHORT).show()
                }
            }
        }

    //Cloud Storageへのアップロード
    private fun uploadImage(uri: Uri) {
        val storageRef = storage.reference
        val stream = contentResolver.openInputStream(uri)
        val userImageRef = storageRef.child("image/${uri.path}")
        val uploadTask = userImageRef.putStream(stream!!)
        uploadTask.addOnSuccessListener {
            Log.d("cloud storage", "upload success")
            stream.close()
        }.addOnFailureListener { error ->
            Log.d("cloud storage", "upload failure $error")
            }
    }

}

〈kotlin〉ExoPlayer

Android studioでkotlinを使ってAndroidアプリ作成の勉強中。

Androidで音声・動画の再生に使用できるExoPlayerライブラリを触ってみた。
ExoPlayerはMediaPlayer APIをもとに作られていて、
より多くの機能をサポートしており、拡張やカスタマイズも容易になっている。
exoplayer.dev

今回はこのcodelabをもとにした。
Media streaming with ExoPlayer  |  Android Developers

準備

ExoPlayerはGithubでホストされるオープンソースプロジェクトだったが、
Media3のライブラリに含められたのでMedia3から取得する。

dependencies {
    ...
    implementation "androidx.media3:media3-exoplayer:$mediaVersion"
    implementation "androidx.media3:media3-ui:$mediaVersion"
    implementation "androidx.media3:media3-exoplayer-dash:$mediaVersion"
}

URLからインターネット上の動画でも再生できるようにManifestファイルにpermissionを追加。

<uses-permission android:name="android.permission.INTERNET"/>

レイアウト

<androidx.media3.ui.PlayerView
        android:id="@+id/video_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

基本の動画再生

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    private var player: ExoPlayer? = null
    
    private var playWhenReady = true //再生・一時停止の状態を保存
    private var currentItem = 0 //メディアアイテムのインデックスを保存
    private var playbackPosition = 0L //再生位置を保存

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
    }

    /*
    APIレベル24以上では、複数のウィンドウをサポートしており、分割ウィンドウモードではアプリは視認可能だがアクティブにならないため、onStartでプレーヤーを初期化する。
    APIレベル23以下では、アプリがリソースを取得するまでできるだけ長く待機する必要があるため、プレーヤーを初期化する前に onResume まで待つ。
    */
    override fun onStart() {
        super.onStart()
        if(Util.SDK_INT > 23){
            initializePlayer()
        }
    }

    override fun onResume() {
        super.onResume()
        hideSystemUi()   //全画面表示にするために他のUIを非表示にする
        if ((Util.SDK_INT <= 23 || player == null)) {
            initializePlayer()
        }
    }

    private fun hideSystemUi() {
        WindowCompat.setDecorFitsSystemWindows(window, false)
        WindowInsetsControllerCompat(window, binding.videoView).let { controller ->
            controller.hide(WindowInsetsCompat.Type.systemBars())
            controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
        }
    }

    private fun initializePlayer(){
        player = ExoPlayer.Builder(this)
            .build()
            .also { exoPlayer ->
                binding.videoView.player = exoPlayer
               
                val mediaItem = MediaItem.fromUri(getString(R.string.media_url_mp4))
                exoPlayer.setMediaItem(mediaItem)
                exoPlayer.playWhenReady = playWhenReady
                exoPlayer.seekTo(currentItem, playbackPosition) //特定のアイテムの特定の位置から開始

                exoPlayer.prepare()
            }
    }

    /*
    APIレベル23以下では、onStop が呼び出される保証がないため、onPause でできるだけ早くプレーヤーを解放する。
    APIレベル24以上では、onStopが呼び出されることが保証される。一時停止状態ではアクティビティが引き続き表示されるため、onStopまで待ってからプレーヤーを解放する。
   */
    override fun onPause() {
        super.onPause()
        if(Util.SDK_INT <= 23){
            releasePlayer()
        }
    }

    override fun onStop() {
        super.onStop()
        if(Util.SDK_INT > 23){
            releasePlayer()
        }
    }

    private fun releasePlayer() {
        //playerを破棄する前に情報を保存しておき、中断したところから再開できるようにする。
        player?.let { exoPlayer ->
            playbackPosition = exoPlayer.contentPosition
            currentItem = exoPlayer.currentMediaItemIndex
            playWhenReady = exoPlayer.playWhenReady
            exoPlayer.release()
        }
        player = null
    }
}

playerは多くのリソースを占有する可能性があり、使用していない時には解放するよう、
playerのライフサイクルをアプリのライフサイクルに関連づけておく(onStart、onResumeでの初期化とonPause、onStopでの解放)。

プレイリストの作成

playerに対してMediaItemを追加することで複数のメディアアイテムの連続再生ができる。

val firstItem = MediaItem.fromUri(firstVideoUri)
val secondItem = MediaItem.fromUri(secondVideoUri)
player.addMediaItem(firstItem)
player.addMediaItem(secondItem)

moveMediaItem, removeMediaItemなどで編集をすることも可能。
詳細はhttps://exoplayer.dev/playlists.html

アダプティブストリーミング

利用可能なネットワーク帯域幅に基づいて動画の再生品質を変え、ユーザーが快適に再生を続けることができるようにするストリーミング。
メディアコンテンツを品質(ビットレートと解像度)が異なる複数のトラックに分割して用意して、プレーヤーが利用可能なネットワーク帯域幅に基づいてトラックを選択する。
アダプティブ ストリーミングにはDASHというフォーマットがよく使われる。
DASH URIにはファイル拡張子がなくMIMEタイプを指定する必要があるため、MediaItem.Builder()を使用してMediaItemを作成する。

private fun initializePlayer() {
    //メディアアイテム内のトラックを選択するtrackSelectorを作成、パラメータで選択条件を指定する
    val trackSelector = DefaultTrackSelector(this).apply {
        setParameters(buildUponParameters().setMaxVideoSizeSd())
    }
     player = ExoPlayer.Builder(this)
            .setTrackSelector(trackSelector)
            .build()
            .also { exoPlayer ->
                binding.videoView.player = exoPlayer
                val mediaItem = MediaItem.Builder()
                    .setUri(getString(R.string.media_url_dash))
                    .setMimeType(MimeTypes.APPLICATION_MPD)
                    .build()

                exoPlayer.setMediaItem(mediaItem)
      ...
}