20200628のAndroidに関する記事は8件です。

Androidのウィジェットでできること、できないこと

iOS 14でホーム画面ウィジェットが使えるようになるそうです。Androidでは昔からウィジェットがありましたが、このタイミングでAndroidのウィジェットにももう一度注目が集まりそうな予感がします。しかし、ウィジェットはかなり制約が大きく、何ができて何ができないのかをエンジニア以外に理解してもらうのは難しかったりします。
ここでは、どうやって作るかという話ではなく、Android開発に詳しくない人や非エンジニア向けに、ウィジェットってどういうものなのか、何ができて何ができないのかをざっくり理解していただけるような内容で書いてみようと思います。

ウィジェットの制約の考え方

あるアプリのウィジェットを表示しているのは、そのアプリ自身ではなく、ホームアプリ等のウィジェットが設置されているアプリです。ウィジェットを提供するアプリは、こういう内容を表示してくださいとお願いすることしかできません。
まずはここを押さえておいてもらえば以降の制約についても理解しやすいかもしれません。

実際に表示しているのは別のアプリなので、ウィジェットが表示されている間、アプリのプロセスは生きているとは限りません。更新のタイミングでは起動しますが、更新のタイミング以外では、アプリは何もしていないのでプロセスも終了している可能性があります。当然バックグラウンドで動作することになるので、バックグラウンド動作の制約もかかります(位置情報へのアクセスに別のパーミッションが必要など)

全部を制御できてほぼ何でもありのアプリの画面とは全然ちがって、できないことが多く、仮にできたとしても、かなり工夫しないといけません。

使えるView(UIのパーツ)が限られる

アプリの画面上では何でもありですが、ウィジェットは使うことができるView(UIのパーツ)が限定されています。

レイアウトは以下の4種類

  • FrameLayout
    • レイヤーを重ねるようにViewを重ねて表示するLayoutです。それぞれの要素の上下左右のマージンを指定できるので親を絶対座標とする配置が可能です
  • LinearLayout
    • Viewを縦、もしくは横方向に並べるLayoutです
  • RelativeLayout
    • 他のViewを基準に相対的な位置関係を指定して配置していくViewです。自由度が高いですがLegacy扱いになっています
  • GridLayout
    • Viewを格子状に配置するLayoutです

Viewは以下のみ

  • AnalogClock
    • アナログ時計を表示するViewです。ただし、API 23(Android 6)で非推奨になっています
  • Button
    • ボタン、テキストだけでなく画像を表示させることもできます
  • Chronometer
    • カウントアップ・ダウンのデジタルタイマーです
  • ImageButton
  • ImageView
  • ProgressBar
    • 進捗を表示させるバーです。いわゆるクルクルもProgressBarのバリエーションです。

  • TextView
    • テキストと画像を表示させることができます
  • ViewFlipper
    • スライドショー形式で複数の表示の切り替えを自動再生できます。(スクショでは表現できませんが一定時間ごとに表示を切り替えることができます)
  • ListView
    • 複数の要素を縦に並べるViewです。画面に入りきらない場合はスクロール動作になります
  • GridView
    • ListViewに列数を指定できるようにしたものです、画面に入りきらない場合はスクロール動作になります
  • StackView
    • 複数の要素を重ねて表示させるViewです
  • AdapterViewFlipper
    • できることはViewFlipperと同じですが、実装方法が異なります

ポイントは、この制限は本当にこのViewしか使えないということです。
Viewは拡張元、拡張先の親子関係を持っているのですが、ここに上げたViewの親や子は使うことができません。

すべてのViewは「View」というクラスを拡張して作られていますが、Viewは使える要素に含まれていないため、View自体を使うことができません。
同様に独自に拡張したものやライブラリ提供のViewも一切使うことができません。

できるのは、ここにあるViewを組み合わせて使うことだけです。

OSバージョンによる動作の違いが大きく出る

通常アプリ開発では、AppCompatという新OSの機能を古いOS上でも使えるようにして、OSバージョン間の差異を吸収するライブラリを使用します。
使えるViewの中にあるView、例えば、TextViewも実際はAppCompatTextViewというTextViewを拡張したクラスが使われますが、ウィジェットではAppCompatのクラスは使えません。

OSバージョン間の差異を吸収するライブラリが使えませんので、当然OSバージョン間での動作の違いなどが現れやすく、かつ、それが問題となった場合に解決が困難です。

Viewの制限によりできなくなることの代表例

Viewに制限があって使えるのはこれだけです、といわれても、何ができて何ができないのかピンとこないでしょう。
具体的にこれできる?と言われそうでできないことの例を上げてみようと思います。

  • Webコンテンツの表示
    • WebViewは使えません
  • 動画コンテンツの表示
    • VideoViewやSurfacaViewは使えません
  • 3Dレンダリング
    • GLSurfaceViewは使えません

Viewに対する命令の制限

使えるViewに制限がある話をしましたが、使えるViewに対しても可能な命令が制限されます。
この制限をエンジニア以外に分かってもらうのは難しいですが、わかりやすいところで言うと、表示させられる画像リソースは、ビルド時に固定で組み込んだリソース、もしくはBitmapのみという制約があります。
例えば、角丸や縁取りの色や太さを動的に変更できるようにして欲しいというリクエストをされると、すごくハードルが上がります。

操作

ウィジェットで受け取ることができる操作はタップのみです。それ以外の操作を受け取ることはできません。
スクロール可能なViewを配置している場合は、スクロール操作がウィジェットに適用されますが、その操作をアプリで受け取ることはできません。

そのため、例えば、お絵かきソフトのようにドラッグした軌跡を書き出すといったことはできません。

表示場所の制御、取得

  • ウィジェットが追加された
  • ウィジェットが削除された
  • 表示サイズが変わった

といった情報は受け取ることができるため、どのような大きさで何個設置されているか、をアプリから知ることは可能です。
しかし、それがどのランチャーアプリに設置されているのか、ランチャーアプリ内のどこページのどの位置なのか、といった情報を取ることはできません。
ウィジェットがユーザーから見える位置にあるか否かも知ることはできません。

表示の更新

ウィジェットの属性で更新周期を指定することができますが、30分以下を指定すると無視され、更新されなくなります。
端末のWakeupが伴うため、1時間に1回以下にすることが推奨されています。

これとは別に、アプリ側から更新を要求することができます。
このときは当然アプリのプロセスが生きている必要がありますが、能動的な更新が可能なので、アプリ側の定期実行の仕組みを使えば、ある程度柔軟な制御ができます。また、ウィジェット上のボタンのタップなどでアプリにメッセージを飛ばすことができますので、このタイミングで更新をかけることもできます。

いずれにせよ、アプリはバックグラウンドで動作することになりますので、高頻度な更新はできないと思った方がよいでしょう。

当然アニメーションもあらかじめ決められた動きの一部は可能ですが、自由度はかなり限られます。アプリがバックグラウンドで常駐して、表示する内容を高速に更新、という方法は使えなくもないでしょうが、垂直同期を取ることはできませんし、常駐状態でこのような処理をするのは現実的ではないです。無理にぶん回せばこのぐらいの更新はできるみたいですが

表示サイズの制御

表示サイズとして指定できるのは以下です

  • デフォルトの高さ、幅
  • 最小の高さ、幅
  • 高さ、幅それぞれが可変か否か

セル数でサイズの指定はできない

まず、高さ、幅の指定で使う単位は dp です。一般的なランチャーアプリでは、「3 x 1」のように表示されるセル数で表示されているため、セル数を指定したくなるかもしれませんが、これは不可能です。セルのサイズおよびマージンは端末やランチャーアプリによって異なります。場合によっては縦向きか横向きかでも変わります。ウィジェットを提供するアプリはデフォルトのレイアウトが収まる具体的なサイズを指定し、ホームアプリは自分のセルサイズから計算して、それが納まる最小のセル数を表示します。
セル数サイズが変わっては困る仕様は避ける必要があります(例えば名前に「1 x 1ウィジェット」とサイズの入った名前をつけるなど)

上限は指定できない

高さ、幅、それぞれについて、デフォルト、最小、可変か否か、しか指定できません。上限がないため、可変にすると上限なしとするしかありません。
デフォルトの大きさは「1 x 1」だけど、「1 x 2」までは拡大できる、それ以上はできない、みたいな仕様は実現できません。
広い領域が割り当てられても、その中心にだけ表示するという制御は可能です。

まとめ

思いつく範囲で、Androidのウィジェットの制約をまとめました。
あれが書かれてない、これ間違っている、などありましたらご指摘をお願いします。

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

リップルカラーの変更方法

はじめに

Buttonのリップルカラーを変更したいと思ったが、思い通りの色に変更することができなかった。 ここではButton(Material)のリップルカラーの変更方法の手順をまとめます。

目標

以下のようなOutlineButtonを作成します。

Layout

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

    <com.google.android.material.button.MaterialButton
        android:id="@+id/button"
        style="@style/Widget.MaterialComponents.Button.OutlinedButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Button"
        android:theme="@style/Theme.MaterialComponents"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:strokeColor="?attr/colorPrimary"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

リップルカラーをいじっていないボタンを生成します。 この状態だとリップルカラーはcolorPrimaryになっているのと思います。

今回は黒のリップルカラーにしたいので、rippleColorというアトリビュートを変えればいいのか!と思って黒に変更してみたが。。。

activity_main.xml
...
<com.google.android.material.button.MaterialButton
 ...
 app:rippleColor="@color/colorBlack"
...


あれ、なんか濃くね?
ここで、デフォルトのボタンがどのようになっているかxmlをたどってみました。
<item name="rippleColor">@color/mtrl_btn_text_btn_ripple_color</item>このmtrl_btn_text_btn_ripple_colorを見てみると、

mtrl_btn_text_btn_ripple_color.xml
<selector xmlns:android="http://schemas.android.com/apk/res/android">

  <item android:alpha="@dimen/mtrl_low_ripple_pressed_alpha" android:color="?attr/colorPrimary" android:state_pressed="true"/>
  <item android:alpha="@dimen/mtrl_low_ripple_focused_alpha" android:color="?attr/colorPrimary" android:state_focused="true" android:state_hovered="true"/>
  <item android:alpha="@dimen/mtrl_low_ripple_focused_alpha" android:color="?attr/colorPrimary" android:state_focused="true"/>
  <item android:alpha="@dimen/mtrl_low_ripple_hovered_alpha" android:color="?attr/colorPrimary" android:state_hovered="true"/>
  <item android:alpha="@dimen/mtrl_low_ripple_default_alpha" android:color="?attr/colorPrimary"/>

</selector>

ここでalpha(不透明度)を設定していた。なので、この?attr/colorPrimaryを自分のしたい色に変更してやれば良さそう。

btn_ripple_color.xml
<selector xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:alpha="@dimen/ripple_pressed_alpha" android:color="@color/black" android:state_pressed="true" />
    <item android:alpha="@dimen/ripple_focused_alpha" android:color="@color/black" android:state_focused="true" android:state_hovered="true" />
    <item android:alpha="@dimen/ripple_focused_alpha" android:color="@color/black" android:state_focused="true" />
    <item android:alpha="@dimen/ripple_hovered_alpha" android:color="@color/black" android:state_hovered="true" />
    <item android:alpha="@dimen/ripple_default_alpha" android:color="@color/black" />

</selector>

pressedやfocusedは、ボタンを押した時やフォーカス時の色は設定できます。(正直ボタンをfocuseやhoverってあんまり実感わかない)
作成したxmlファイルを、rippleColorに適応させる。

activity_main.xml
...
<com.google.android.material.button.MaterialButton
 ...
 app:rippleColor="@drawable/btn_ripple_color"
...

以上でリップルカラーを変更できました!

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

FlutterでWeb,Android,iOSの3プラットフォームから同じコードでS3に写真をアップロードする

概要

6月にWeb向けのImagePickerが出ていたので、ネイティブアプリとブラウザアプリを同じソースコードでビルドして、どのデバイスでもファイルアップロード出来るのかというのを試しました。
AndroidとiOSに加えてWebでもPickerが同じ処理で使えるようになったのは結構画期的だなと思ってのメモです。内容はクライアント側に重点を置いています。

1. パッケージをインポートする。

pubspec.yamlにimage_pickerとimage_picker_for_web、amazon_cognito_identity_dart_2を追加する。
私が試したバージョンは以下です。

image_picker: ^0.6.7
image_picker_for_web: ^0.1.0+1
amazon_cognito_identity_dart_2: ^0.1.14

2. 画像取得処理を書く。thenを使用してますが、awaitでも問題ありません。

import 'package:image_picker/image_picker.dart';
~
~
var picker = ImagePicker();
picker.getImage(source: ImageSource.gallery)
    .then((PickedFile value) {
      // ここから必要に応じて後続の処理を呼び出す
    });

3. アップロード処理を書く。

こちら( https://pub.dev/packages/amazon_cognito_identity_dart_2#signing-requests )のページのFor S3 Uploadsをベースにしますが、PickedFileはlengthが取れないので少し改造します。PolicyやSigV4はそのままでmain()だけ変更します。
私の場合はuploadImageとして以下のようにしました。

  Future<String> uploadImage(PickedFile file) async {
    final _credentials = CognitoCredentials(ID_POOL_ID, _userPool);
    final _cognitoUser = CognitoUser(_username, _userPool);
    final authDetails =
    AuthenticationDetails(username: _username, password: _passwd);
    CognitoUserSession _session;
    try {
      _session = await _cognitoUser.authenticateUser(authDetails);
    } catch (e) {
      print(e);
      return null;
    }
    await _credentials.getAwsCredentials(_session.getIdToken().getJwtToken());
    final uri = Uri.parse(_s3Endpoint);
    final req = http.MultipartRequest("POST", uri);
    final multipartFile = http.MultipartFile.fromBytes('file', await file.readAsBytes(),contentType: MediaType('application', 'octet-stream'));
    logger.d('Policy.fromS3PresignedPost');
    var uuid = Uuid();
    String filename = 'test.jpg';
    final policy = Policy.fromS3PresignedPost(
        filename,
        _backetName,
        150,
        _credentials.accessKeyId,
        10000000,
        _credentials.sessionToken,
        region: REGION);
    final key = SigV4.calculateSigningKey(
        _credentials.secretAccessKey, policy.datetime, REGION, 's3');
    final signature = SigV4.calculateSignature(key, policy.encode());
    req.files.add(multipartFile);
    req.fields['key'] = policy.key;
    req.fields['acl'] = 'public-read';
    req.fields['X-Amz-Credential'] = policy.credential;
    req.fields['X-Amz-Algorithm'] = 'AWS4-HMAC-SHA256';
    req.fields['X-Amz-Date'] = policy.datetime;
    req.fields['Policy'] = policy.encode();
    req.fields['X-Amz-Signature'] = signature;
    req.fields['x-amz-security-token'] = _credentials.sessionToken;
    try {
      final res = await req.send();
      logger.d('res:${res.toString()}');
      await for (var value in res.stream.transform(utf8.decoder)) {
        logger.d(value);
      }
      return filename;
    } catch (e, stacktrace) {
      logger.e(e);
      logger.e(stacktrace);
    }
    return null;
  }
}

ID_POOL_IDや_userPoolは各環境に合わせて定義してください。
fromS3PresignedPostへ渡すパラメータmaxFileSizeは一旦10Mとしているので必要に応じて変更してください。
リンク先と主に異なるのは、final file = File(path.join('/path/to/my/folder', 'square-cinnamon.jpg'));PickedFileに置き換わっていて、
streamからMultipartFileを生成する処理

final stream = http.ByteStream(DelegatingStream.typed(file.openRead()));
final multipartFile = http.MultipartFile('file', stream, length,
      filename: path.basename(file.path));


bytesからMultipartFileを生成する処理

final multipartFile = http.MultipartFile.fromBytes('file', await file.readAsBytes(),contentType: MediaType('application', 'octet-stream'));

になっております。目的としてはlengthが取れないからbytesでのリクエストにしました。
クライアント側はこんな感じです。

他に

  • Cognitoのユーザプール作成
  • IDプール作成
  • IAM追加
  • S3のCORS設定
  • iOSのNSPhotoLibraryUsageDescription

が必要です。特にS3のCORS設定はブラウザからのみアップロードできない問題に直面するので、ハマりポイントとして記載します。(その他は調べれば出てくると思うので他の方の記事をご参照ください。)

4. サーバ側番外編 S3でCORSの設定

こちら( https://docs.aws.amazon.com/ja_jp/sdk-for-javascript/v2/developer-guide/cors.html )のCORS設定例を参考にS3のバケット>アクセス権限>CORSの設定で設定してください。AllowedOriginだけ各環境に合わせて変える必要があります。
ローカル環境からの試しであればアスタリスク*で設定すると早いです。

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <CORSRule>
    <AllowedOrigin>https://example.org</AllowedOrigin>
    <AllowedMethod>HEAD</AllowedMethod>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedMethod>POST</AllowedMethod>
    <AllowedMethod>DELETE</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
    <ExposeHeader>ETag</ExposeHeader>
    <ExposeHeader>x-amz-meta-custom-header</ExposeHeader>
  </CORSRule>
</CORSConfiguration>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

フォークしたリポジトリをライブラリとして取り込む

ケースとしてはあまり多くないとおもいますが、掲題のとおりフォークしたライブラリをプロジェクトに取り込む方法について記載します

経緯としては、以下のYahooのライブラリを改造して使いたかったためです。
https://github.com/yahoojapan/AppFeedback-android

通常なら類似のライブラリを探すのでしょうけど、今回は見当たらなかったためForkすることにしてみました。

  1. Forkが完了すると以下な感じにリダイレクトします
    https://github.com/harutamasato/AppFeedback-android

  2. sdkディレクトリ内でいじりたい箇所をいじって、masterにコミットしていきます。

$ git commit -a -m "Message"
$ git push origin master
  1. 使える状態になったらタグをきってPushします。 2020/6/28現在、オリジナルの最新が1.0.5なのでそれ以上のものを設定します。 今回は1.0.6とします。正常にPushされると、以下のページのTagとReleaseに 1.0.6が並びます。
$ git tag -a "1.0.6"
$ git push origin 1.0.6
  1. ライブラリを使う側はプロジェクトレベルのbuild.gradleに以下がなければ追記
build.gradle
allprojects {
    repositories {
        maven { url "https://jitpack.io" }
    }
}
  1. 最後に以下をapp/build.gradleに追記してSyncして使うだけでOK。 app/build.gradle dependencies { implementation 'com.github.harutamasato.AppFeedback-android:sdk:1.0.6' }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

別途アプリをインストールせずに、決まった時間にAndroid端末でアプリを起動する

きっかけ

日付や時間帯を指定して、起動したいアプリがあります。たとえばmineoアプリなど、毎日一回アプリを起動させて、起動ボーナスの回収と「ゆずるね」の宣言を行いたい場合などがあります。

そういうときのためにAutomateItなどのアプリもあるのですが、なにぶんAutomateItは多機能な分端末にかける負荷も大きく、普段使いするような端末にはあまり入れたくありません。

そういうときには、Join by Joaoapps(以下Join)のAPIを使う という方法があります。これを使うとAPI経由で特定のURLをブラウザに開かせたり、アプリを起動させたりといったことがURLを叩くだけで実現できます。

やりかた

まず自分のAPI KEYとデバイスIDを確認するため、Join On The Webを開きます。

image.png

なぜか文字が重なって表示されてしまうのですが、とりあえずクリックすれば端末名は表示されるので、起動したい端末を選びます。

次に、JOIN APIのボタンをクリックします。

image.png

API Key: の右にある「SHOW」ボタンを押したあと、画面をスクロールして「App Package To Launch」の行に、起動したいアプリのパッケージ名を入れます。

image.png

PCで確認することが困難な場合は、Playストアでアプリのページを開くと確認できます。

image.png

ついでJoin On The Webの画面で上の方にスクロールすると、「Fill in fields below then select -> Copy URL to use」の行に「joinjoaomgcd.appspot.com」からはじまる長いURLが表示されていますので、これをコピーします。

image.png

このURLを定期的に実行されるような何かで呼び出すようにすればOKです。

たとえばPowerShellの場合は、次のようなコードになります。

curl "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush?appPackage=jp.mineo.app.mineoapp&deviceId=...&apikey=..."

GASで定期的に実行する

ローカルにファイルを置いてしまうとバックアップやPC復元時の設定が面倒なので、こういうときはGoogle Apps Scriptなどで処理してしまうと楽です。

GoogleドライブでGoogle Apps Scriptのプロジェクトを作成し、次のようなコードを書きます。

コード.gs
function run() {
  const url = "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush?appPackage=jp.mineo.app.mineoapp&deviceId=...&apikey=..."
  UrlFetchApp.fetch(url) 
}

これを保存し、トリガーの設定で、「毎日n時台に実行」などとやっておけばOKです。

image.png

動作テストをしたい場合は「時間ベースのトリガーのタイプを選択」を「分ベースのタイマー」、「時間の間隔を選択(分)」を「1分おき」などとしておくと、1分後にスクリプトが実行されるので動作を確認しやすいですね。

おまけ:JOIN APIのエントリポイント

JOIN APIの解説ページを見ると、見出しが「API Key」と「Send Push」と「Notification Field」と「List Devices」しかなく、URLっぽいものが「List Devices」の真下にしかないため、一見して「どう呼べば良いんだ?」と思ってしまうこともあると思います(わたしはあった)

ただ先にJoin On The Webのほうをちゃんと見れば分かるとおり、「Send Push」のエントリポイントを叩くURLはJoin On The Webのほうを確認すれば分かりますので、ここには記載されていません。

ちなみに、下にある「List Devices」というAPIもちゃんとあり、Joinに登録されている(アカウントと紐付けされた)デバイスの情報が出力されます。

curl "https://joinjoaomgcd.appspot.com/_ah/api/registration/v1/listDevices?apikey={Join On The WebのJOIN APIボタンにて確認できるAPI Key}"

注意点

特にないですが、Join On The Webに書かれているとおりAPI Keyさえ分かってしまえば認証など一切無しでデバイスが操作できますので、API Keyが外部に漏れないように気をつけましょう。

また、Join On The Webで文字が重なってしまうことなどから、端末名はちゃんと指定するようにしましょう。

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

パッケージ名からアプリ名(AndroidManifestのlabel属性のもの)を取得する

暗黙的Intentで選択したアプリ名を使いたくて、パッケージ名からアプリ名を取得する必要があったため備忘録含め以下に展開します。

こちらのgetApplicationLabelを使うと以下のようになります。
loadLabelでも可

なお、パッケージ名はいくつか取得方法があると思いますがいつかよく使う、URLをブラウザで開くケースを載せておきます。

Case1.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContentView(R.layout.activity_main)
    val launchIntent = Intent(Intent.ACTION_VIEW)
    val url = "https://www.google.com/?hl=ja"
    launchIntent.data = Uri.parse(url)

    val intentPick = Intent()
    intentPick.action = Intent.ACTION_PICK_ACTIVITY
    intentPick.putExtra(Intent.EXTRA_TITLE, "Launch using")
    intentPick.putExtra(Intent.EXTRA_INTENT, launchIntent)
    startActivityForResult(intentPick, 1)
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == 1) {
        val packageName = data?.component!!.packageName // パッケージ名取得方法①
        val appName = packageManager.getApplicationLabel(packageName, PackageManager.GET_META_DATA).toString()
        startActivity(data)
    }
}
Case2.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    val url = "https://www.google.com/?hl=ja"
    val intent = Intent(Intent.ACTION_VIEW)
    intent.data = Uri.parse(url)
    val pendingIntent = PendingIntent.getBroadcast(
            this,
            123,
            Intent(this, ShareBroadcastReceiver::class.java),
            PendingIntent.FLAG_UPDATE_CURRENT
    )

    val chooser = Intent.createChooser(intent, null, pendingIntent.intentSender)

    button.setOnClickListener {
        if (intent.resolveActivity(packageManager) != null) {
            startActivity(chooser)
        }
    }
}

class ShareBroadcastReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        val packageName2 = intent?.getParcelableExtra<ComponentName>(Intent.EXTRA_CHOSEN_COMPONENT)?.packageName // パッケージ名取得方法②
        val keys = Objects.requireNonNull(intent!!.extras)?.keySet()
        if (keys != null) {
            for (key in keys) {
                try {
                    val packageName = (intent!!.extras!![key] as ComponentName?)?.packageName// パッケージ名取得方法③
                    val packageManager: PackageManager? = context?.packageManager
                    val appName = packageManager?.getApplicationLabel(packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA)) as String
                } catch (e: Exception) {
                    e.printStackTrace()
                }
            }
        }
    }
}

上記で取得できるパッケージ名例は以下

com.android.chrome
org.mozilla.firefox
jp.co.fenrir.android.sleipnir

取得できるラベル名は以下(※必ずしもアプリ名とは一致しません)

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

【Android】DataBindingのvariableはcamelCaseで書くのが無難

<variable name="hoge_text" type="String">

より、

<variable name="hogeText" type="String">

のが困らないと思うっすという話。プチハマったので。

具体例

「被<include>ファイルでsnake_caseのvariableを使おうとしたらアクセスできなかった」

書いてみるxml

child.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="value_text"
            type="String" />
    </data>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@{value_text}"/>
</layout>
parent.xml
<親要素省略>
    <include
        layout="@layout/child"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:value_text="@{someVariable}"/>
</親要素省略>

生成されるコード

ChildBindingImpl.java
    public void setValueText(@Nullable java.lang.String ValueText) {
        this.mValueText = ValueText;
        synchronized(this) {
            mDirtyFlags |= 0x2L;
        }
        notifyPropertyChanged(BR.value_text);
        super.requestRebind();
    }
ParentBindingImpl.java
    @Nullable
    private final app.package.name.databinding.ChildBinding mboundView;
// ~~~ 略 ~~~
            this.mboundView.setValue_text(someVariableGetValue); // <- お前ー!!

エラー内容

エラー: シンボルを見つけられません
            this.mboundView3.setValue_text(someVariableGetValue);
                            ^
  シンボル:   メソッド setValue_text(String)

というわけで、app:snake_case属性により生成される関数名が、
<variable name="snake_case">により生成される関数名と異なるためビルドに失敗します。

「じゃあapp:camelCase&<variable name="snake_case">ならどうよ」という気もしますが、
その場合はxmlファイル側で名前の解決が出来ず、BindingImplの生成前にエラーになるようです。書く側としてもxml内でその切り替えはしんどいっす。(自分の場合)

対処

冒頭に書いた通りです。

child.xml
<layout xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="valueText"
            type="String" />
    </data>
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@{valueText}"/>
</layout>
parent.xml
<親要素省略>
    <include
        layout="@layout/child"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:valueText="@{someVariable}"/>
</親要素省略>

定義からcamelCaseにしましょう。

おまけ

あんまり明言はされてないと思うんですが、
公式のサンプルでも2単語の変数名をさがしました。camelCaseでした。(userList

    <data>
        <import type="com.example.User"/>
        <import type="java.util.List"/>
        <variable name="user" type="User"/>
        <variable name="userList" type="List&lt;User>"/>
    </data>

このあたり(じゃあresourceのidもcamelCaseにしとく? とか)はGoogle提供のコードでも一貫されていない感があるので、昔から小さく話題になることみたいですね。

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

【Kotlin】Firebase + CameraX でリアルタイム文字認識

Firebase ML Kit + CameraX でリアルタイム文字認識してBottomSheetに表示します。

デモ

完成形はこんな感じです。

おおまかな処理の流れ

カメラ起動

MLKitでリアルタイム文字認識

BottomSheetに認識したテキストをリアルタイム表示

さっそく作る

開発環境

・Windows 10
・Android Studio 3.6.3

事前準備

1. Firebaseの設定

今回はテキスト認識とラベリングを使用します。
以下URLを参照して設定してください。

・テキスト認識
https://firebase.google.com/docs/ml-kit/android/recognize-text?hl=ja
今回はデバイスモデルを使用します。
デバイスモデルで認識可能な言語はラテン文字のみです。
その他日本語などを認識したい場合はクラウドモデルを使いましょう。(月1000回まで無料のようです)

・ラベリング
https://firebase.google.com/docs/ml-kit/android/label-images?hl=ja

2. CameraXの設定

build.gradle(Module.app)ファイルのdependenciesブロックに以下を追記します。

build.gradle(Module.app)
dependencies {
    
    def camerax_version = "1.0.0-beta03"
    // CameraX core library using camera2 implementation
    implementation "androidx.camera:camera-camera2:$camerax_version"
    // CameraX Lifecycle Library
    implementation "androidx.camera:camera-lifecycle:$camerax_version"
    // CameraX View class
    implementation "androidx.camera:camera-view:1.0.0-alpha10"
    
}

同じくbuild.gradle(Module.app)ファイルのandroidブロック末尾に以下を追記します。

build.gradle(Module.app)
android {
    
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

3. AndroidManifestに追記

AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.websarva.wings.android.your_project_name">

    <uses-feature android:name="android.hardware.camera.any" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

    <application
     ><meta-data
        android:name="com.google.firebase.ml.vision.DEPENDENCIES"
        android:value="ocr, label" />
    </application>
</manifest>

以上を書き込んだらAndroidStudioの "SyncNow" ボタンをクリックし、無事ビルドされることを確認します。

レイアウトを作成する

activity_mainの内容を以下に置き換えます。
LinearLayoutでBottomSheetを作っています。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <LinearLayout
        android:id="@+id/bottomSheetLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/cardview_light_background"
        android:orientation="vertical"
        app:behavior_hideable="false"
        app:behavior_peekHeight="200dp"
        app:layout_behavior="@string/bottom_sheet_behavior">

        <TextView
            android:id="@+id/bottomSheetText"
            android:layout_width="300dp"
            android:layout_gravity="center"
            android:layout_height="wrap_content"
            />
    </LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

メイン処理を書く

大枠としてはこんな感じです。
これから具体的な処理を肉付けしていきます。

MainActivity.kt
typealias ODetection = (odt: Array<String?>) -> Unit
private const val TAG = "CameraXBasic"

class MainActivity : AppCompatActivity() {
    companion object {
        private const val TAG = "CameraXBasic"
        private const val REQUEST_CODE_PERMISSIONS = 10
        private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // TODO: not yet implement
    }

    //MARK:  ===== カメラ起動 =====
    private fun startCamera() {
        // TODO: not yet implement
    }

    class ImageAnalyze (private val listener: ODetection): ImageAnalysis.Analyzer {
        //TODO: not yet implement
    }
}

カメラの処理を書いていきます。
onCreate内に以下の処理を書き足します。

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

        // BottomSheetを設定
        bottomSheetBehavier = BottomSheetBehavior.from(bottomSheetLayout)

        // Request camera permissions
        if (allPermissionsGranted()) {
            startCamera()
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
            )
        }
        // Setup the listener for take photo button
        outputDirectory = getOutputDirectory()
        cameraExecutor = Executors.newSingleThreadExecutor()
    }

onCreateの下に以下のメソッドを書き足します。

MainActivity.kt
    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults:
        IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                startCamera()
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.",
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    private fun getOutputDirectory(): File {
        val mediaDir = externalMediaDirs.firstOrNull()?.let {
            File(it, resources.getString(R.string.app_name)).apply { mkdirs() } }
        return if (mediaDir != null && mediaDir.exists())
            mediaDir else filesDir
    }

startCamera内に以下を書き足します。

startCamera(MainActivity.kt)
    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        val frameLayout = FrameLayout(this)
        cameraProviderFuture.addListener(Runnable {
            // Used to bind the lifecycle of cameras to the lifecycle owner
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            // Preview
            preview = Preview.Builder()
                .build()

            imageCapture = ImageCapture.Builder()
                .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
                .build()

            imageAnalyzer = ImageAnalysis.Builder()
                .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                .build()
                .also {
                    // OCRの結果
                    it.setAnalyzer(cameraExecutor, ImageAnalyze { txtArr ->
                        var showTxt = ""
                        frameLayout.removeAllViews()
                        for (txt in txtArr){
                            txt?.let{
                                showTxt += " $txt"
                            }
                        }
                        bottomSheetText.text = showTxt
                        Log.d(TAG, "listener fired!: $showTxt")
                    })
                }

            // Select back camera
            val cameraSelector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build()

            try {
                // Unbind use cases before rebinding
                cameraProvider.unbindAll()

                // Bind use cases to camera
                camera = cameraProvider.bindToLifecycle(
                    this, cameraSelector, preview, imageCapture, imageAnalyzer)
                preview?.setSurfaceProvider(viewFinder.createSurfaceProvider(camera?.cameraInfo))
            } catch(exc: Exception) {
                Log.e(TAG, "Use case binding failed", exc)
            }

        }, ContextCompat.getMainExecutor(this))
    }

画像処理部分を書いていきます。
インナークラスであるImageAnalyzeに以下を追記します。

ImageAnalyze(MainActivity.kt)
    class ImageAnalyze (private val listener: ODetection): ImageAnalysis.Analyzer {
        val options = FirebaseVisionOnDeviceImageLabelerOptions.Builder()
            .setConfidenceThreshold(0.7f)
            .build()
        val labeler = FirebaseVision.getInstance().getOnDeviceImageLabeler(options)

        val detector = FirebaseVision.getInstance()
            .onDeviceTextRecognizer

        private fun degreesToFirebaseRotation(degrees: Int): Int = when (degrees) {
            0 -> FirebaseVisionImageMetadata.ROTATION_0
            90 -> FirebaseVisionImageMetadata.ROTATION_90
            180 -> FirebaseVisionImageMetadata.ROTATION_180
            270 -> FirebaseVisionImageMetadata.ROTATION_270
            else -> throw Exception("Rotation must be 0, 90, 180, or 270.")
        }

        // フレームごとに呼ばれる
        override fun analyze(image: ImageProxy) {
            // Pass image to an ML Kit Vision API
            doObjectClassification(image)
        }

さらにImageAnalyzeクラスにラベリング用処理を書き足します。
認識結果が "Paper" の時のみOCR処理が走るようにします。

ImageAnalyze(MainActivity.kt)
        // 画像分類
        @SuppressLint("UnsafeExperimentalUsageError")
        private fun doObjectClassification(proxy: ImageProxy) {
            val mediaImage = proxy.image ?: return
            val imageRotation = degreesToFirebaseRotation(proxy.imageInfo.rotationDegrees)
            val image = FirebaseVisionImage.fromMediaImage(mediaImage, imageRotation)
            labeler.processImage(image)
                .addOnSuccessListener { labels ->
                    // Task completed successfully
                    for (label in labels) {
                        val text = label.text
                        Log.d(TAG, "text: $text")
                        if (text == "Paper") {
                            doTextRecognition(image)
                        } else {
                            // do something
                        }
                    }
                    proxy.close()
                }
                .addOnFailureListener { e ->
                    // Task failed with an exception
                    Log.e(TAG, e.toString())
                    proxy.close()
                }
        }

さらにImageAnalyzeクラスにテキスト認識用処理とパース処理を書き足して完成です。
Runしてみましょう。

ImageAnalyze(MainActivity.kt)
        //文字認識 - 書類に書かれた文字のみ認識する
        private fun doTextRecognition(image: FirebaseVisionImage) {
            val result = detector.processImage(image)
                .addOnSuccessListener { firebaseVisionText ->
                    // Task completed successfully
                    parseResultText(firebaseVisionText)
                    Log.d(TAG, "OCR Succeeded!")
                }
                .addOnFailureListener { e ->
                    // Task failed with an exception
                    Log.d(TAG, "OCR Failed...")
                    Log.e(TAG, e.toString())
                }
        }
        // パース - OCRで認識された文字列をParseする
        private fun parseResultText(result: FirebaseVisionText) {
            var resultTxtList:Array<String?> = arrayOf(null)
            for (block in result.textBlocks) {
                val blockText = block.text
                resultTxtList += blockText
            }
            Log.d("RESULT_TEXT",resultTxtList.toString())
            listener(resultTxtList)
        }

さいごに

CameraX + FIrebaseの組み合わせでかんたんにリアルタイム画像処理アプリが作れました。
従来のCameraライブラリよりもより楽に実装することができるので、今後も使っていきたいです。

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