20210325のAndroidに関する記事は4件です。

ActivityResultAPIのテストを書く

概要

startActivityForResultonActivityResultが非推奨になったので、ActivityResultAPIを使って書き直しました。
で、公式にFragment向けのActivityResultRegistryを置き換えてテストできる方法について触れている箇所があったのですが、

Activityでテストする場合のことが書いてなかったので調べて対応しました。

環境

ツールなど バージョンなど
MacbookPro macOS Catalina 10.15.7
Android Studio 4.1.2
Java(JDK) openjdk version "11.0.10"
Koin 2.2.2
activity-ktx 1.2.1

対応方法

KoinというDIライブラリを使って実現しました。
同じようなDIのライブラリを使ったり、あるいはMockitoで頑張れば似たようなことが出来るかと思います。

0. ライブラリなど

Koinとテスト関連のものを抜粋しています。
同じコードでInstrumentationテストとRobolectricで動かせるように重複して登録している物が多いため長くなっています。

app/build.gradle
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.2.0'
    implementation 'androidx.core:core-ktx:1.3.2'
    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
    implementation 'com.google.android.material:material:1.3.0'
    implementation 'androidx.fragment:fragment-ktx:1.3.1'
    implementation 'androidx.activity:activity-ktx:1.2.1'

    testImplementation 'junit:junit:4.13.2'
    testImplementation 'org.assertj:assertj-core:3.19.0'
    testImplementation 'androidx.test.ext:junit:1.1.2'
    testImplementation 'androidx.test:runner:1.3.0'
    testImplementation 'androidx.test:rules:1.3.0'
    testImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    testImplementation 'androidx.test.espresso:espresso-contrib:3.3.0'
    testImplementation 'androidx.test.espresso:espresso-intents:3.3.0'

    // robolectric
    testImplementation 'org.robolectric:robolectric:4.5.1'

    androidTestImplementation 'androidx.test:runner:1.3.0'
    androidTestImplementation 'androidx.test:rules:1.3.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.3.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.2'
    androidTestImplementation 'org.assertj:assertj-core:3.19.0'
    androidTestUtil 'androidx.test:orchestrator:1.3.0'

    // Koin for Kotlin apps
    def koin_version = "2.2.2"
    // Testing
    testImplementation "org.koin:koin-test:$koin_version"
    androidTestImplementation "org.koin:koin-test:$koin_version"

    // Koin for Android
    implementation "org.koin:koin-android:$koin_version"
    // Koin AndroidX Scope feature
    implementation "org.koin:koin-androidx-scope:$koin_version"
    // Koin AndroidX ViewModel feature
    implementation "org.koin:koin-androidx-viewmodel:$koin_version"

1. 対象のActivityをScopedActivityにする

FooActivity.kt
class FooActivity : ScopedActivity() {

2. Koinのモジュールを作る

Koinの説明は割愛しますので、詳しくは調べてください。

(1)モジュールを作る

modules.kt
val scopeModules = module {
    scope<FooActivity> {
        scoped { get<AppCompatActivity>().activityResultRegistry }
    }
}
// モジュール群
val appModules = listOf(
    scopeModules
)

(2)アプリケーションクラスでKoinを設定する

MyApp.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin {
            if (BuildConfig.DEBUG) androidLogger(Level.DEBUG) else EmptyLogger()
            androidContext(this@MyApp)
            modules(appModules)
        }
    }
}

マニフェストファイルにアプリケーションクラスを設定するのもお忘れなく。

AndroidManifest.xml
   <application
            android:name=".MyApp"
            ...>

(3)registerForActivityResultの第2引数を変更する

registerForActivityResultの第2引数をget()に変更します。

FooActivity.kt
    private val activityResultLauncher =
        registerForActivityResult(
          ActivityResultContracts.StartActivityForResult(), get()) {
            onBarActivityResult(it)
        }

これでKoinが同じクラスを返すモジュールを探してInjectionしてくれます。

3. テスト用のモジュール作成

(1)テスト用のActivityResultRegistryを定義

TestModules.kt
    class TestResultRegistry() :
        ActivityResultRegistry() {
        override fun <I, O> onLaunch(
            requestCode: Int,
            contract: ActivityResultContract<I, O>,
            input: I,
            options: ActivityOptionsCompat?
        ) {
            when (/* 条件 */) {
                // caseに応じたdispatchResult処理
                dispatchResult(requestCode, /*リザルトコード*/, /*リザルトデータ*/)
            }
        }
    }

期待する結果を入れておく変数や関数などは適宜作成してください。

(2)差し替えモジュール作成

テストの際に差し替えるモジュールを作成します。

TestModules.kt
val testMockModule = module {
    scope<FooActivity> {
        scoped(override = true) { TestResultRegistry() as ActivityResultRegistry }
    }
}

4. テストクラス

(1)テストクラスを作成

androidTestで作っていきます。

FooActivityTest.kt
@RunWith(AndroidJUnit4::class)
class FooActivityTest : AutoCloseKoinTest() {

    @get:Rule
    val rule = InstantTaskExecutorRule()

    @Before
    fun setUp() {
        loadKoinModules(testMockModule)
    }
    @Test
    fun bar() {
        ActivityScenario.launch(FooActivity::class.java).use { scenario ->
            scenario.onActivity {
            }

            // 別のActivityを起動させる
            onView(withId(R.id.barActivityLaunchButton)).perform(click())

            // resultがすぐにディスパッチされているので、結果を受け取って表示されているべき内容をチェックする
            onView(withText(getString(R.string.barbar)))
                .check(matches(isDisplayed()))
        }
    }
}

注意としては、この方法だと、直ぐに結果がdispatchされるため実際にはActivityは何も起動していない点です。なのでActivityMonitorを使って起動したActivityのチェックを行うことは出来ません。

そこで、次のように必要なテストに応じて追加の差替えモジュールをkoinに読ませるようにすると良いかも知れません。

その場合、testMockModuleからTestResultRegistryは除外しておく必要があります。

(2)テストごとに追加モジュールを使う

TestModules.kt
val testMockModule = module {
//scope<FooActivity> {
//        scoped(override = true) { TestResultRegistry() as ActivityResultRegistry }
//    }
}
FooActivityTest.kt
    @Test
    fun bar_isLaunched() {
        // ResultActivityの起動を監視
        val monitor = Instrumentation.ActivityMonitor(
          BarActivity::class.java.canonicalName, null, false
        )
        InstrumentationRegistry.getInstrumentation().addMonitor(monitor)
        onView(withId(R.id.barActivityLaunchButton)).perform(click())

        // ResultActivityが起動したか確認
        InstrumentationRegistry.getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L)
        assertThat(monitor.hits).isEqualTo(1)
    }

    @Test
    fun bar_resultCheck() {
        val testRegistry = TestRegistry()
        // 追加のモジュール
        val scopedModule = module {
            scope<FooActivity> {
                scoped(override = true) { testRegistry as ActivityResultRegistry }
            }
        }
        loadKoinModules(scopedModule)

        ActivityScenario.launch(FooActivity::class.java).use { scenario ->
            scenario.onActivity {
            }

            // 別のActivityを起動させる
            onView(withId(R.id.barActivityLaunchButton)).perform(click())

            // resultがすぐにディスパッチされているので、結果を受け取って表示されているべき内容をチェックする
            onView(withText(getString(R.string.barbar)))
                .check(matches(isDisplayed()))
        }
    }

5.Roblectric版

Robolectricでも同じコードで動くのを確認しています。
ただし、Roblectricで動かしている場合はEspressoがonViewでダイアログ上のViewを拾えないので、ダイアログを表示している場合にはテストコードを100%流用することが出来ません。ご注意下さい。
(ActivityResultAPI関係の所は同じで動くはずです)

参考サイトなど

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutter アプリ内課金(サブスクリプリプション)の実装

内容

今回初めて、アプリ内課金(サブスクリプション、以下サブスク)の実装をしたので、忘却録として書きます。
2021/3/12 時点のサンプルコードを使用。
サブスク導入でつまずいた部分のみを記載(2021/03/26時点ではiOSのつまずき集のみ)。実装は気が向いたら書く予定。

テスト中に何回も購入処理をして、トランザクションが残っていると色々と大変らしいので、YuKiOさんの記事を先に読んでおくことをおすすめします。

前提条件

●App Store, Google Play側の準備ができている(サブスクアイテム作成)
●今回はサブスクのみなので、consumable(消耗品)関係は未対応

以下のような感じに複数のアイテムをセットしています

Set<String> _kProductIds = {
 Platform.isIOS
     ? 'ios.subscription01'
     : 'android.subscription01',
 Platform.isIOS
     ? 'ios.subscription02'
     : 'android.subscription02',
 Platform.isIOS
     ? 'ios.subscription03'
     : 'android.subscription03',
};

環境

PC:MacBook Pro
エディター:Visual Studio Code

Flutter:1.22.6

in_app_purchase:0.3.5+1

つまずき集

サンプルコードを修正しながら実行してみて動きを見ていた時のつまずき集です
サンプルコードはこちら

つまずきその1

 シミュレータでデバッグしたら、return されてしまう

    productDetailResponse = await _connection.queryProductDetails(_kProductIds);
    if (productDetailResponse.error != null) {
      setState(
        () {
          _queryProductError = productDetailResponse.error.message;
          _isAvailable = isAvailable;
          _products = productDetailResponse.productDetails;
          _purchases = [];
          _notFoundIds = productDetailResponse.notFoundIDs;
          _consumables = [];
          _purchasePending = false;
          _loading = false;
        },
      );
      return;
    }

解決方法:シミュレータで実行していることが原因らしく、実機をつないで、デバッグしたらここで、returnしないらしいです(iPhone11で確認しました)
こちらを参考にしました

つまずきその2

 サブスク用に各プラットフォームで作成したアイテムを_kProductIdsにセットしたが、見つからないと画面に表示されてしまう
画像のように[ios.subscription01,・・・] not foundと表示される

ファイル名

    productDetailResponse = await _connection.queryProductDetails(_kProductIds);
    if (productDetailResponse.productDetails.isEmpty) {
      setState(
        () {
          _queryProductError = null;
          _isAvailable = isAvailable;
          _products = productDetailResponse.productDetails;
          _purchases = [];
          _notFoundIds = productDetailResponse.notFoundIDs;
          _consumables = [];
          _purchasePending = false;
          _loading = false;
        },
      );
      return;
    }

解決方法:こちらもシミュレータではproductDetailResponse.productDetails.isEmptyがtrueになってしまうので、実機に接続することで、解決できました
プラグインの公式ページにも以下のように記載がありました

Use flutter run to install the app and test it.
Note that you need to test it on a real device instead of a simulator, and signing into any production service (including iTunes!)
with the test account will permanently invalidate it.
Sign in to the test account in the example app following the steps in the In-App Purchase Programming Guide.

つまずきその3

 SANDBOXアカウントの変更ができない
サブスク購入ボタンをクリック時にアカウントを入力するダイアログが表示され、一度、入力したユーザーが変更できなかった
解決方法:こちらの記事を参考にすると、SANDBOXアカウントの変更ができる

つまずきその4

 SANDBOXアカウントの変更をしてもアイテムの購入ができない
 購入しても、purchaseDetails.status が purchased にならず、購入後の処理が実行されない

解決方法:SANDBOXアカウントのアドレスを存在するアドレスにする
(たまたまそうなっただけで、正しいかどうかは未確認)

つまずきその5

 購入フォームが2回表示される

解決方法:つまずき4が解決したら表示されなくなった
githubにも上がってたけど、解決はしていない?
原因は不明のまま

つまずき6

 保護者へのリクエストが表示され、購入が進まない

解決方法:githubに書いてありますが、PurchaseParamメソッドの引数のsandboxTestingをfalseにする。

 PurchaseParam purchaseParam = PurchaseParam(
                    sandboxTesting: false,
                    productDetails: productDetails);

参考

公式

YuKiOさんのQiita

スタディプラスのブログ

mediumの記事

●各プラットフォームのアイテムの作成方法
・iOS
Qiita

・Android
Qiita

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

自作のアダプタークラスでclear()を使う

問題のコード

ListWorkActivity.java
public class ListWorkActivity extends AppCompatActivity implements View.OnClickListener {
    private ArrayList<Record> listItems = new ArrayList<>();
    private DateArrayAdapter arrayAdapter;
    private ListView listView;

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_list);
//listViewにlistItemsをセットしたい
        listView = findViewById(R.id.listView);
        {...}
//nullじゃないlistView(ここは省略)と自作のDateArrayAdapter 
        arrayAdapter = new DateArrayAdapter(this, R.layout.date_list, listItems);
        listView.setAdapter(arrayAdapter);
   }


//menu内のボタンをクリックするとListViewの中身が逆順になるようにしたい
public boolean onOptionsItemSelected(MenuItem menuItem){
        int item = menuItem.getItemId();
        switch (item) {
            {...}
            case R.id.menu_button:
                //button
                //逆順にする
                Collections.reverse(listItems);
                arrayAdapter.clear();
                arrayAdapter.addAll(listItems);
                arrayAdapter.notifyDataSetChanged();
                break;
        }
        return false;
    }

自作のアダプタークラス

ListViewにカスタマイズしたレイアウトで要素を表示するためのクラスです。

DateArrayAdapter.java
public class DateArrayAdapter extends ArrayAdapter<Record> {

    private int mResource;
    private List<Record> mItems;
    private LayoutInflater mInflater;

    /**
     * コンストラクタ
     * @param context コンテキスト
     * @param resource レイアウトファイルのリソースID
     * @param items リストビューの要素
     */

    public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){
        super(context, resource, items);

        mResource = resource;
        mItems = items; //ArrayList -> List
        mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

    }


    @Override
    public int getCount() {
        return mItems.size();
    }

    @Override
    public Record getItem(int position) {
        return mItems.get(position);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View view;

        if (convertView != null){
            view = convertView;
        }else{
            view = mInflater.inflate(mResource, null);
        }

        Record record;
        //リストビューに表示する要素を取得
        record = getItem(position);

        //date
        TextView textViewDate = view.findViewById(R.id.textViewDate_DateLayout);
        textViewDate.setText(record.getDate());

        //work
        TextView textViewWork = view.findViewById(R.id.textViewWork_DateLayout);
        textViewWork.setText(record.getWork());

        //memo
        TextView textViewMemo = view.findViewById(R.id.textViewMemo_DateLayout);
        textViewMemo.setText(record.getMemo());

        return view; //may not be null
    }
}

Record.javaはデータ格納用のクラスです
Record.java
public class Record {
    //日付、work、メモを格納するクラス
    private String date;
    private String work;
    private String memo;

    //空のコンストラクタ
    public Record(){ }

    //コンストラクタ
    public Record(String date, String work, String memo){
        this.date = date;
        this.work = work;
        this.memo = memo;
    }


    public String getDate() {
        return date;
    }

    public String getWork() {
        return work;
    }

    public String getMemo() {
        return memo;
    }
}

起こった問題について

やりたかったこと

・listViewの中身を逆順にして表示する

実際はどうなったか

・listViewの要素であるlistItemは逆順になった
・clear()をするとlistItemsが消えた

//onOptionsItemSelected内の処理
Collections.reverse(listItems); //ここでlistItemsを逆順にすることには成功
arrayAdapter.clear(); //listItems の要素数が0になる = listItemsが消えた!?
arrayAdapter.addAll(listItems);
arrayAdapter.notifyDataSetChanged();

何が問題だったか

スクリーンショット 2021-03-25 114518.png

これによると、コンストラクタにリストなどを渡してしまうと、そのリストは不変になってしまい、clear()を使うと例外が発生するということです。
DateArrayAdapterのコンストラクタではsuper(context, resource, items) (items = listView)を呼び出していたのでうまくいかなかったわけです。

解決法

ArrayAdapterにはほかにもコンストラクタがあるので、T[]を使わないコンストラクタを呼び出しました。

DateArrayAdapter.java
public class DateArrayAdapter extends ArrayAdapter<Record> {

    private int mResource;
    private List<Record> mItems;
    private LayoutInflater mInflater;

    /**
     * コンストラクタ
     * @param context コンテキスト
     * @param resource レイアウトファイルのリソースID
     * @param items リストビューの要素
     */

//問題のコンストラクタ。引数にリストを渡している。
    public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){
        super(context, resource, items);

        mResource = resource;
        mItems = items; //ArrayList -> List
        mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

    }

//新しいコンストラクタ。引数にarrayListは渡していない。
    public DateArrayAdapter(Context context, int resource){
        super(context, resource);

        mResource = resource;
        mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

//新しいコンストラクタではmItemsを設定できないので、セッターを用いる。
//これがないとほかのメソッドがmItemsを使えなくて動かなくなるので注意
    public void setItems(List<Record> mItems) {
        this.mItems = mItems;
    }

//あとは同じです。
    @Override
    public int getCount() {
        return mItems.size();
    }

    {...}
}
ListWorkActivity.java
//アダプターをセットするところがこんな感じに変わる
arrayAdapter = new DateArrayAdapter(this, R.layout.date_list);
arrayAdapter.addAll(listItems);
arrayAdapter.setItems(listItems);
listView.setAdapter(arrayAdapter);

ほかにいい解決法がありそうなら教えていただけると嬉しいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ListView用のカスタムアダプターの中身を逆順にするときにおこった問題について

問題のコード

ListWorkActivity.java
public class ListWorkActivity extends AppCompatActivity implements View.OnClickListener {
    private ArrayList<Record> listItems = new ArrayList<>();
    private DateArrayAdapter arrayAdapter;
    private ListView listView;

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_list);
//listViewにlistItemsをセットしたい
        listView = findViewById(R.id.listView);
        {...}
//nullじゃないlistView(ここは省略)と自作のDateArrayAdapter 
        arrayAdapter = new DateArrayAdapter(this, R.layout.date_list, listItems);
        listView.setAdapter(arrayAdapter);
   }


//menu内のボタンをクリックするとListViewの中身が逆順になるようにしたい
public boolean onOptionsItemSelected(MenuItem menuItem){
        int item = menuItem.getItemId();
        switch (item) {
            {...}
            case R.id.menu_button:
                //button
                //逆順にする
                Collections.reverse(listItems);
                arrayAdapter.clear();
                arrayAdapter.addAll(listItems);
                arrayAdapter.notifyDataSetChanged();
                break;
        }
        return false;
    }

自作のアダプタークラス

ListViewにカスタマイズしたレイアウトで要素を表示するためのクラスです。

DateArrayAdapter.java
public class DateArrayAdapter extends ArrayAdapter<Record> {

    private int mResource;
    private List<Record> mItems;
    private LayoutInflater mInflater;

    /**
     * コンストラクタ
     * @param context コンテキスト
     * @param resource レイアウトファイルのリソースID
     * @param items リストビューの要素
     */

    public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){
        super(context, resource, items);

        mResource = resource;
        mItems = items; //ArrayList -> List
        mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

    }


    @Override
    public int getCount() {
        return mItems.size();
    }

    @Override
    public Record getItem(int position) {
        return mItems.get(position);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View view;

        if (convertView != null){
            view = convertView;
        }else{
            view = mInflater.inflate(mResource, null);
        }

        Record record;
        //リストビューに表示する要素を取得
        record = getItem(position);

        //date
        TextView textViewDate = view.findViewById(R.id.textViewDate_DateLayout);
        textViewDate.setText(record.getDate());

        //work
        TextView textViewWork = view.findViewById(R.id.textViewWork_DateLayout);
        textViewWork.setText(record.getWork());

        //memo
        TextView textViewMemo = view.findViewById(R.id.textViewMemo_DateLayout);
        textViewMemo.setText(record.getMemo());

        return view; //may not be null
    }
}

Record.javaはデータ格納用のクラスです
Record.java
public class Record {
    //日付、work、メモを格納するクラス
    private String date;
    private String work;
    private String memo;

    //空のコンストラクタ
    public Record(){ }

    //コンストラクタ
    public Record(String date, String work, String memo){
        this.date = date;
        this.work = work;
        this.memo = memo;
    }


    public String getDate() {
        return date;
    }

    public String getWork() {
        return work;
    }

    public String getMemo() {
        return memo;
    }
}

起こった問題について

やりたかったこと

・listViewの中身を逆順にして表示する

実際はどうなったか

・listViewの要素であるlistItemは逆順になった
・clear()をするとlistItemsが消えた

//onOptionsItemSelected内の処理
Collections.reverse(listItems); //ここでlistItemsを逆順にすることには成功
arrayAdapter.clear(); //listItems の要素数が0になる = listItemsが消えた!?
arrayAdapter.addAll(listItems);
arrayAdapter.notifyDataSetChanged();

何が問題だったか

スクリーンショット 2021-03-25 114518.png

これによると、コンストラクタにリストなどを渡してしまうと、そのリストは不変になってしまい、clear()を使うと例外が発生するということです。
DateArrayAdapterのコンストラクタではsuper(context, resource, items) (items = listView)を呼び出していたのでうまくいかなかったわけです。

解決法

ArrayAdapterにはほかにもコンストラクタがあるので、T[]を使わないコンストラクタを呼び出しました。

DateArrayAdapter.java
public class DateArrayAdapter extends ArrayAdapter<Record> {

    private int mResource;
    private List<Record> mItems;
    private LayoutInflater mInflater;

    /**
     * コンストラクタ
     * @param context コンテキスト
     * @param resource レイアウトファイルのリソースID
     * @param items リストビューの要素
     */

//問題のコンストラクタ。引数にリストを渡している。
    public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){
        super(context, resource, items);

        mResource = resource;
        mItems = items; //ArrayList -> List
        mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);

    }

//新しいコンストラクタ。引数にarrayListは渡していない。
    public DateArrayAdapter(Context context, int resource){
        super(context, resource);

        mResource = resource;
        mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    }

//新しいコンストラクタではmItemsを設定できないので、セッターを用いる。
//これがないとほかのメソッドがmItemsを使えなくて動かなくなるので注意
    public void setItems(List<Record> mItems) {
        this.mItems = mItems;
    }

//あとは同じです。
    @Override
    public int getCount() {
        return mItems.size();
    }

    {...}
}
ListWorkActivity.java
//アダプターをセットするところがこんな感じに変わる
arrayAdapter = new DateArrayAdapter(this, R.layout.date_list);
arrayAdapter.addAll(listItems);
arrayAdapter.setItems(listItems);
listView.setAdapter(arrayAdapter);

ほかにいい解決法がありそうなら教えていただけると嬉しいです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む