JavaプロジェクトでAndroidアプリの通信部品クラスを開発する

#### Androidアプリの通信部品クラスをJavaプロジェクトで開発する方法について解説します。

　Android Studio はバージョンアップを重ねるごとに快適に動作させるためのPCのリソース要件が高くなってきています。**ただでさえ重い環境でＵＩに関係しない通信部品クラスの開発までも Android Studio で行うのは効率的ではないと私個人は思っています。**

　今回紹介するAndroidのスタブクラスを作成することで、より軽量なIDEでのJavaプロジェクトで通信部品クラスの開発が可能になります。

:::note info
この記事の実装の元ネタとなった Android 公式ドキュメントは下記で、ほぼこのサイトで紹介されている方法で通信部品を作成します。※Volley, OkHttpなどの外部ライブラリは使用しない実装になっています。
[バックグラウンド スレッドでの Android タスクの実行 (Java編)](https://developer.android.com/guide/background/threading?hl=ja#java)
+ 複数スレッドの作成
+ バックグラウンド スレッドでの実行
+ メインスレッドとの通信
+ ハンドラの使用
+ スレッドプールの設定
:::

:::note warn
残念なことに上記サイトで解説されている実装例はそのままで動作させれるような完成形になっていません。JavaによるAndroidアプリの開発経験が浅い人がこのドキュメントだけで実装するのは難しいと思いこの記事を投稿することにしました。
※公式サンプル検索サイトで検索しましたがソースに該当するサンプルは見つかりませんでした。
[Developers サンプル](https://developer.android.com/samples?hl=ja)
:::

### 開発環境

+ OS: Ubuntu 22.04
+ JDK: java-11-openjdk-amd64  
※ Android sourceCompatibility: Java version 11
+ Java IDE: IntelliJ IDEA Community Edition for linux  
※ Linux用のアーカイブをダウンロード
+ ライブラリ: Gson JSON library (Bundle-Version: 2.9.0)

 
### 1. サーバー側アプリのレスポンス

　正常時(該当データ有り)に下記のような画像データ(base64エンコード文字列)を含むデータをJSONで返却する。

<img width="350" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/3c7be8f1-8311-b217-d853-f0a0f2dd8c40.png">

### 1-1. リクエスト

+ プロトコル: HTTP(S)
+ メソッド: GET
+ リクエストパラメータ(パス形式): /<デバイス名>/検索日付(ISO8601形式)
+ リクエストヘッダー: **X-Request-Image-Size** 必須
 AndoidアプリのImageViewサイズと端末の密度を設定  
 (例) X-Request-Image-Size: 1064x1336x2.750000

Google Pixel4aのリクエストヘッダー

```
Content-Type: application/json;charset=utf8
Accept: application/json;
X-Request-Network-Type: wifi
X-Request-Image-Size: 1064x1336x2.750000
User-Agent: Dalvik/2.1.0 (Linux; U; Android 13; Pixel 4a Build/TQ3A.230805.001)
Host: raspi-4.local:12345
Connection: Keep-Alive
Accept-Encoding: gzip
```

### 1-2. レスポンス

+ レスポンスの出力先
    + 正常時: 標準出力
    ※ Java側では **[接続オブジェクト].getInputStream**()で正常レスポンスを受信する 
    + リクエストパラメータエラー時: エラー出力
    ※ Java側では **[接続オブジェクト].getErrorStream**()でエラーレスポンスを受信する 
+ レスポンス形式: JSON形式
+ 画像データ("**img_src**")の形式: **画像データ(バイト)の base64エンコード文字列**

#### 1-2-1. 処理正常 (画像データ有り)

```json
{
  "data": {
    "img_src": "data:image/png;base64,iVBORw0KGgoAAAA...[途中省略]...ElFTkSuQmCC",
    "rec_count": 112
  },
  "status": {
    "code": 0,
    "message": "OK"
  }
}
```

#### 1-2-2. 処理正常 (画像データ無し)

```json
{
  "data": {
    "img_src": null,
    "rec_count": 0
  },
  "status": {
    "code": 0,
    "message": "OK"
  }
}
```

#### 1-2-3. エラーレスポンス (400系, 500系エラー)

```json
{
  "status": {
    "code": 400, 
    "message": "423,device_name not found"
  }
}
```

### 2. Androidスタブクラス一覧

通信部品クラスにインポートされる Android標準クラスに対応するスタブクラス

```
src
├── android
│   ├── app
│   │   └── Application.java       # 必須
│   ├── content
│   │   └── res
│   │       └── AssetManager.java  # 必要に応じて作成
│   ├── os
│   │   ├── Handler.java           # 必須
│   │   └── Looper.java            # 必須
│   └── util
│       └── Log.java               # 必須 ※通信クラスのDEBUGログ出力に必要
├── androidx
│   └── core
│       └── os
│           └── HandlerCompat.java # 必須
└── resources  # リクエストURL等の定義ファイル(JSON)
    └── request_info.json          # 必要に応じて作成
```

#### 2-1. Applicationクラス

　サブクラスでバックグラウンドスレッドでリクエストを処理するスレッドプールを提供するために継承します。  

```Application.java
package android.app;

public class Application {
    public Application() {
    }

    public void onCreate() {
        // No ope
    }
}
```

#### 2-2. AssetManagerクラス

　想定するAndroidアプリはリクエスト情報(JSONファイル)を assets領域に格納し、AssetManagerクラスを通して読み込みするように実装しています。Javaアプリでもリクエスト情報を読み込むだけのスタブを作成します。
※リクエスト情報を文字列としてクラスに保持する場合はこのスタブは不要です。

```AssetManager.java
package android.content.res;

import android.util.Log;

import java.io.*;
import java.net.URL;

/**
 * android.content.AssetManagerの擬似クラス
 * [res]ディレクトリのリクエスト用JSONファイルを読み込み
 */
public class AssetManager {
    private static final String TAG = "AssetManager";

    private static final String RESOURCES_ROOT = "resources/";

    public AssetManager() {
    }

    public InputStream open(String filename) throws FileNotFoundException {
        File resFile = getResourceFile(filename);
        Log.d(TAG, "file: " + resFile);
        return new FileInputStream(resFile);
    }

    private File getResourceFile(final String fileName) throws FileNotFoundException {
        String resFile = RESOURCES_ROOT + fileName;
        URL url = this.getClass()
                .getClassLoader()
                .getResource(resFile);
        if (url != null) {
            return new File(url.getFile());
        }
        throw new FileNotFoundException(resFile + " not found!");
    }

}
```

#### 2-3. Handlerクラス

Runableインターフェースのrunメソッドをメインスレッドで実行するスタブ

```Handler.java
package android.os;

/** android.os.Handlerの擬似クラス */
public class Handler {
    public void post(Runnable runnable) {
        // Javaアプリケーシヨンでは Runableクラスのrun()を実行
        try {
            runnable.run();
        } catch (Exception e) {
            System.out.println(e.getLocalizedMessage());
        }
    }
}
```

#### 2-4. Looperクラス

ダミーのLooperオブジェクトを返却するだけのスタブ

```Looper.java
package android.os;

public class Looper {
    public static Looper getMainLooper() {
        return new Looper();
    }
}
```

#### 2-5. Logクラス

ログメッセージを標準出力するスタブ

```Log.java
package android.util;

/**
 * android.util.Logの擬似クラス
 * System.out.printlin()でコンソールに出力
 */
public class Log {
    private Log() {}
    public static void d(String tag, String message) {
        System.out.println("D/" + tag + ":" + message);
    }
    public static void w(String tag, String message) {
        System.out.println("W/" + tag + ":" + message);
    }
    public static void e(String tag, String message) {
        System.out.println("E/" + tag + ":" + message);
    }
}
```

#### 2-6. AndroidXのヘルパークラス

Handlerの機能にアクセスするためのヘルパークラスのスタブ

```HandlerCompat.java
package androidx.core.os;

import android.os.Handler;
import android.os.Looper;

public class HandlerCompat {
    public static Handler createAsync(Looper lopper) {
        return new Handler();
    }
}
```

#### 2-7. リクエスト情報(JSONファイル)

ネットワークス種別に応じたリクエスト先URL、必須ヘッダー情報を定義

```src/resouces/request_info.json
{
  "urls": {
    "wifi": "http://dell-t7500.local:5000/plot_weather",
    "mobile": "http://raspi-4-dev.local:5000/plot_weather"
  },
  "headers": {
    "X-Request-Network-Type": "wifi"
  }
}
```

Androidアプリでは app/src/[**main|debug**]/**assets** に格納される request_info.json に該当するファイルで AssetManagerで読み込む。
```app/src/debug/assets/request_info.json
{
  "urls": {
    "wifi": "http://dell-t7500.local:5000/plot_weather",
    "mobile": "http://raspi.dreamexample.com:12345/plot_weather"
  },
  "headers": {
    "X-Request-Network-Type": "wifi"
  }
}
```

### 3. 通信部品クラス一覧

Javaプロジェクトのパッケージを Androidアプリのパッケージに合わせます

```
src
└─ com
   └─ dreamexample
      └─ android
         └─ weatherviewer
            ├─ data                          # JSONレスポンス復元クラス ※Android 依存なし
            │  ├─ ResponseImageData.java
            │  ├─ ResponseImageDataResult.java
            │  ├─ ResponseStatus.java
            │  └─ ResponseWarningStatus.java
            ├─ functions
            │  └─ MyLogging.java            # Android 依存有り (android.util.Log)
            └─ tasks
               ├─ RepositoryCallback.java     # 受信完了コールバックインターフェース
               ├─ Result.java                 # 受信結果クラス (正常、ウォーニング、例外)
               ├─ WeatherGraphRepository.java # 画像取得時のリクエストパス定義
               ├─ WeatherImageRepository.java # 画像取得JSONレスポンスからオブジェクト復元
               └─ WeatherRepository.java      # リクエスト発行とJSONレスポンス受信処理
```


#### 3-1. JSONレスポンスを復元するクラス

(1) 正常レスポンス (画像データ有り | 画像データ無し)
![FlaskResponseToJavaClasses.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/37251cc7-6860-a9ea-c497-ab81d5c74d67.jpeg)

(2) エラーレスポンス (リクエストパラメータNG)
![FlaskResponseNgStatus.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/acedd5d9-929b-4174-84ca-b748c796e283.jpeg)


##### 3-1-1. ResponseImageDataクラス 

+ 画像データ(base64エンコード文字列)をデコードしたバイトデータを返却するGetterを定義

```ResponseImageData.java
package com.dreamexample.android.weatherviewer.data;

import com.google.gson.annotations.SerializedName;

import java.util.Base64;

import static com.dreamexample.android.weatherviewer.functions.MyLogging.DEBUG_OUT;

/**
 * 画像取得リクエストに対するレスポンス(RAWデータ)
 * Gsonライブラリで変換
 * [形式] JSON
 */
public class ResponseImageData {
    // 画像(png)のbase64エンコード文字列
    @SerializedName("img_src")
    private final String imgSrc;
    // 検索データ件数
    @SerializedName("rec_count")
    private final int recCount;

    public ResponseImageData(String imgSrc, int recCount) {
        this.imgSrc = imgSrc;
        this.recCount = recCount;
    }

    public byte[] getImageBytes() {
        if (this.imgSrc != null) {
            // img_src = "data:image/png;base64, ..base64string..."
            String[] datas = this.imgSrc.split(",");
            DEBUG_OUT.accept("ResponseImageData", "datas[0]" + datas[0]);
            return Base64.getDecoder().decode(datas[1]);
        } else {
            return null;
        }
    }

    public int getRecCount() { return recCount; }

    @Override
    public String toString() {
        return "ResponseImageData{" +
                "imgSrc.size='" + (imgSrc != null ? imgSrc.length() : 0) + '\'' +
                ", recCount=" + recCount +
                '}';
    }
}
```

##### 3-1-2. ResponseStatusクラス (正常レスポンス時のみ)

```ResponseStatus.java
package com.dreamexample.android.weatherviewer.data;

public class ResponseStatus {
    private final int code;
    private final String message;

    public ResponseStatus(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }

    public String getMessage() { return message; }

    @Override
    public String toString() {
        return "ResponseStatus{" +
                "code=" + code +
                ", message='" + message + '\'' +
                '}';
    }
}
```

##### 3-1-3. ResponseWarningStatusクラス (エラーレスポンス時)

```ResponseWarningStatus.java
package com.dreamexample.android.weatherviewer.data;

public class ResponseWarningStatus {
    private ResponseStatus status;

    public ResponseWarningStatus(ResponseStatus status) {
        this.status = status;
    }
    public ResponseStatus getStatus() {
        return status;
    }

    @Override
    public String toString() {
        return "ResponseWarningStatus{status=" + status + '}';
    }
}
```

##### 3-1-4. ResponseImageDataResultクラス

サーバー側の処理が正常(200)終了したときのレスポンスのデシリアライズクラス

```ResponseImageDataResult.java
package com.dreamexample.android.weatherviewer.data;

/** 画像取得リクエスト用レスポンスクラス */
public class ResponseImageDataResult {
    private final ResponseImageData data;
    private final ResponseStatus status;

    public ResponseImageDataResult(ResponseImageData data, ResponseStatus status) {
        this.data = data;
        this.status = status;
    }

    public ResponseImageData getData() { return this.data; }

    public ResponseStatus getStatus() {
        return status;
    }
}
```

#### 3-2. Repositoryクラス関連

##### 3-2-1. レスポンス(正常|正常以外)と例外時に返却されるオブジェクト定義クラス

```Result.java
package com.dreamexample.android.weatherviewer.tasks;


import com.dreamexample.android.weatherviewer.data.ResponseStatus;

public abstract class Result<T> {
    private Result() {
    }

    public static final class Success<T> extends Result<T> {
        private final T data;

        public Success(T data) {
            this.data = data;
        }

        public T get() {return data; }
    }

    public static final class Warning<T> extends Result<T> {
        private final ResponseStatus status;

        public Warning(ResponseStatus status) {
            this.status = status;
        }

        public ResponseStatus getResponseStatus() { return status; }
    }

    public static final class Error<T> extends Result<T> {
        private final Exception exception;

        public Error(Exception exception) {
            this.exception = exception;
        }

        public Exception getException() { return exception; }
    }
}
```

##### 3-2-2. コールバック

リポジトリクラスが Android のUIスレッドに返却するコールバックインターフェース

```RepositoryCallback.java
package com.dreamexample.android.weatherviewer.tasks;

public interface RepositoryCallback<T> {
    void onComplete(Result<T> result);
}
```

#### 3-3. リポジトリクラス

　バックグラウンドスレッドでリクエストを実行し、レスポンスコードに応じたオブジェクトをUIスレッドに返却する機能を提供する

##### 3-3.(1) ベースクラスの処理

+ レスポンスコードに対応したResultオブジェクトの生成
    + 正常(200): Result.Successオブジェクト(ResponseImageDataResult) 
    + 正常以外(400系、500系) : **エラーストリームからResult.Warningオブジェクト生成**
    + レスポンス処理時の例外発生: Result.Errorオブジェクト(発生した例外)  
    ※IO例外(ネットワーク)、Jsonデシリアライズ例外 (主としてサーバー側のBUG等)

+ リクエストパスはサブクラスで実装
public **abstract String getRequestPath**(...)

+ 正常レスポンス時のデシリアライズはサブクラスで実装  
public **abstract T parseResultJson**(...)

```WeatherRepository.java
package com.dreamexample.android.weatherviewer.tasks;

import static com.dreamexample.android.weatherviewer.functions.MyLogging.DEBUG_OUT;

import android.os.Handler;
import android.util.Log;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonParseException;
import com.dreamexample.android.weatherviewer.data.ResponseStatus;
import com.dreamexample.android.weatherviewer.data.ResponseWarningStatus;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ExecutorService;

public abstract class WeatherRepository<T> {
    private static final String TAG = "WeatherRepository";

    public WeatherRepository() {}

    /**
     * GETリクエスト生成メソッド
     * @param pathIdx パス用インデックス ※サブクラスで複数のGETリクエストパス定義
     * @param baseUrl パスを含まないURL (Wifi | Mobile)
     * @param requestParameter リクエストパラメータ (null可)
     * @param headers リクエストヘッダー
     * @param executor ExecutorService
     * @param handler Android Handlerオブジェクト
     * @param callback UI(Fragment)が結果を受け取るコールバック
     */
    public void makeGetRequest(
            int pathIdx,
            String baseUrl,
            String requestParameter,
            Map<String, String> headers,
            ExecutorService executor,
            Handler handler,
            final RepositoryCallback<T> callback) {
        executor.execute(() -> {
            try {
                String requestUrl = baseUrl + getRequestPath(pathIdx)
                        + (requestParameter != null ? requestParameter: "");
                DEBUG_OUT.accept(TAG, "requestUrl:" + requestUrl);
                Result<T> result = getRequest(requestUrl, headers);
                // 200, 4xx - 50x系
                notifyResult(result, callback, handler);
            } catch (Exception e) {
                // サーバー側のレスポンスBUGか, Android側のBUG想定
                Result<T> errorResult = new Result.Error<>(e);
                notifyResult(errorResult, callback, handler);
            }
        });
    }

    private void notifyResult(final Result<T> result,
                              final RepositoryCallback<T> callback,
                              final Handler handler) {
        handler.post(() -> callback.onComplete(result));
    }

    private Result<T> getRequest(
            String requestUrl, Map<String, String> requestHeaders) {
        HttpURLConnection conn = null;
        try {
            URL url = new URL(requestUrl);
            conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("GET");
            conn.setRequestProperty("Accept", "application/json;");
            for (String key : requestHeaders.keySet()) {
                conn.setRequestProperty(key, requestHeaders.get(key));
            }

            // Check response code: allow 200 only.
            int respCode = conn.getResponseCode();
            DEBUG_OUT.accept(TAG, "ResponseCode:" + respCode);
            if (respCode == HttpURLConnection.HTTP_OK) {
                String respText = getResponseText(conn.getInputStream());
                T result = parseResultJson(respText);
                return new Result.Success<>(result);
            } else {
                // 4xx - 50x
                // Flaskアプリからはエラーストリームが生成される
                String respText = getResponseText(conn.getErrorStream());
                DEBUG_OUT.accept(TAG, "NG.Response.JSON: \n" + respText);
                // ウォーニング時のJSONはデータ部が存在しないのでウォーニング専用ハースを実行
                ResponseStatus status = getWarningStatus(respText);
                return new Result.Warning<>(status);
            }
        } catch (Exception ie) {
            Log.w(TAG, ie.getLocalizedMessage());
            return new Result.Error<>(ie);
        } finally {
            if (conn != null) {
                conn.disconnect();
            }
        }
    }

    /**
     * 入力ストリームからJSON文字列を取得
     * @param is 入力ストリーム
     * @return JSON文字列
     * @throws IOException IO例外
     */
    public String getResponseText(InputStream is) throws IOException {
        StringBuilder sb;
        try (BufferedReader bf = new BufferedReader
                (new InputStreamReader(is, StandardCharsets.UTF_8))) {
            String line;
            sb = new StringBuilder();
            while ((line = bf.readLine()) != null) {
                sb.append(line);
            }
        }
        return sb.toString();
    }

    /**
     * ウォーニング時のレスポンスオブジェクトを取得する
     * <pre>ウォーニング時にサーバーが返却するレスポンス例
     {"status": {"code": 400,"message": "461,User is not found."}}
     * </pre>
     * @param jsonText ウォーニング用JSON文字列
     * @return レスポンスオブジェクト<br/>
     *   ResponseStatusオブジェクトのみがセットされDataオブジェクトはnullがセットされる
     * @throws JsonParseException パース例外
     */
    public ResponseStatus getWarningStatus(String jsonText) throws JsonParseException {
        Gson gson = new GsonBuilder().serializeNulls().create();
        ResponseWarningStatus warningStatus = gson.fromJson(jsonText, ResponseWarningStatus.class);
        return warningStatus.getStatus();
    }

    /**
     * リクエストパスを取得する
     * @param pathIdx パスインデックス (1 - m)
     * @return サブクラスが提供するパス
     */
    public abstract String getRequestPath(int pathIdx);

    /**
     * HTTP 200(OK) レスポンス時のJSON文字列をパースしてJavaオブジェクトを生成
     * @param jsonText JSON文字列を
     * @return サブグラスが定義するJavaオブジェクトを生成
     * @throws JsonParseException GSONのパース例外
     */
    public abstract T parseResultJson(String jsonText) throws JsonParseException;

}
```

##### 3-3-2. 画像取得リポジトリクラスの処理

+ 画像データは100Kバイトを超えるため読み込み処理ではバイトストリームを使用
+ 画像データのデシリアライズ

```WeatherImageRepository.java
package com.dreamexample.android.weatherviewer.tasks;

import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import com.google.gson.Gson;
import com.google.gson.JsonParseException;

import com.dreamexample.android.weatherviewer.data.ResponseImageDataResult;

/**
 * 可視化画像取得リポジトリクラス
 */
public abstract class WeatherImageRepository extends WeatherRepository<ResponseImageDataResult> {
    public WeatherImageRepository() {}
    /**
     * 画像データ取得レスポンスはデータサイズが大きいのでバイトストリームバッファを経由して取得
     * @param is 入力ストリーム
     * @return レスボンス文字列
     */
    @Override
    public String getResponseText(InputStream is) throws IOException {
        String strJson = null;
        try(BufferedInputStream bufferedInput = new BufferedInputStream(is);
            ByteArrayOutputStream out = new ByteArrayOutputStream()) {
            // 画像データは8KB単位でバイトストリーム追加する
            byte[] buff = new byte[8 * 1024];
            int length;
            while ((length = bufferedInput.read(buff)) != -1) {
                out.write(buff, 0, length);
            }
            byte[] imgArray = out.toByteArray();
            if (imgArray.length > 0) {
                // 文字列に復元
                strJson = new String(imgArray, StandardCharsets.US_ASCII);
            }
        }
        return strJson;
    }

    /**
     * 画像データレスポンス
     *
     * @param jsonText JSON文字列
     * @return Gsonで変換したResponseImageResultオブジェクト
     */
    public ResponseImageDataResult parseResultJson(String jsonText) throws JsonParseException {
        Gson gson = new Gson();
        return gson.fromJson(jsonText, ResponseImageDataResult.class);
    }

    public abstract String getRequestPath(int urlIdx);
}
```

##### 3-3-3. 指定日の画像取得リポジトリクラス

+ URLパスを定義する  

Androidのフラグメント1つに対して、1つのリポジトリクラスを対応させます
※タイプの異なる画像を表示するフラグメントが複数あれば、リポジトリクラスも同じ数だけ定義します。

```WeatherGraphRepository.java
package com.dreamexample.android.weatherviewer.tasks;

/** GraphFragment用データ取得リポジトリ */
public class WeatherGraphRepository extends WeatherImageRepository {
    /** 指定日の気象データ画像取得[0], 本日から指定日前[1] */
    private static final String[] URL_PATHS = {"/getdayimageforphone"};

    public WeatherGraphRepository() {}

    @Override
    public String getRequestPath(int pathIdx) {
        return URL_PATHS[pathIdx];
    }
}
```

#### 3-4. Androidアプリケーション擬似クラス

参考ドキュメント(ハンドラの使用)のオリジナルのソースは下記のようになっています。

```java
public class MyApplication extends Application {
    ExecutorService executorService = Executors.newFixedThreadPool(4);
    Handler mainThreadHandler = HandlerCompat.createAsync(Looper.getMainLooper());
}
```

+ スレッドプールとハンドラーの提供
+ リクエスト情報の接続先URLとヘッダー情報を提供

※ このクラスはAssetManagerの取得処理以外はそのままAndroidアプリで使えます  

```WeatherApplication.java
package com.dreamexample.android.weatherviewer;

import static com.dreamexample.android.weatherviewer.functions.MyLogging.DEBUG_OUT;

import android.app.Application;
import android.content.res.AssetManager;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import androidx.core.os.HandlerCompat;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;

import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * android.app.Applicationを継承したカスタムApplicationクラスの擬似クラス
 * Javaアプリでは何も継承しない同名のクラスとするが、機能は同じとする
 */
public class WeatherApplication extends Application {
    private static final String LOG_TAG = "WeatherApplication";
    // Json file in assts for request.
    private static final String REQUEST_INFO_FILE = "request_info.json";
    private Map<String, String> mRequestUrls;
    private Map<String, String> mRequestHeaders;

    public static final String REQUEST_IMAGE_SIZE_KEY = "X-Request-Image-Size";

    // リクエストは10分おきに制限: センサーが10分間隔で測定しているため値が変わらない
    public ExecutorService mEexecutor = Executors.newFixedThreadPool(1);
    // Handerだけは Androidとは異なりダミーのコンストラクタで代用 ※何の処理もしないクラス
    public Handler mHandler = HandlerCompat.createAsync(Looper.getMainLooper());

    @Override
    public void onCreate() {
        super.onCreate();
        try {
            loadRequestConf();
        } catch (Exception e) {
            // ここには来ない想定
            Log.e(LOG_TAG, e.getLocalizedMessage());
        }
    }

    public Map<String, String> getRequestUrls() {
        assert mRequestUrls != null;
        return mRequestUrls;
    }

    public Map<String, String> getRequestHeaders() {
        assert mRequestHeaders != null;
        return mRequestHeaders;
    }
```

※ Androidアプリで使う場合はコメントアウトしている箇所を有効にし、newしている処理を削除します。

```java
    private void loadRequestConf() throws IOException {
        // AssetManager am = getApplicationContext().getAssets();
        AssetManager am = new AssetManager();
        Gson gson = new Gson();
        Type typedMap = new TypeToken<Map<String, Map<String, String>>>() {
        }.getType();
        DEBUG_OUT.accept(LOG_TAG, "typedMap: " + typedMap);
        // gson.fromJson() thorows JsonSyntaxException, JsonIOException
        Map<String, Map<String, String>> map = gson.fromJson(
                new JsonReader(new InputStreamReader(am.open(REQUEST_INFO_FILE))), typedMap);
        mRequestUrls = map.get("urls");
        mRequestHeaders = map.get("headers");
        DEBUG_OUT.accept(LOG_TAG, "RequestUrls: " + mRequestUrls);
        DEBUG_OUT.accept(LOG_TAG, "RequestHeaders: " + mRequestHeaders);
    }
}
```


### 4. Javaメインアプリケーション

#### 4-1. JSONファイルの復元

　HTTPリクエストを実行する前に curl で保存したJSONファイルをJavaオブジェクトに復元できるか確認します。

(1) curl で正常レスポンスをファイルに保存する

```bash
$ curl "http://dell-t7500.local:5000/plot_weather/getdayimageforphone/esp8266_1/2024-01-02" \
> -H "X-Request-Image-Size:1064x1680x2.5"  -o ~/Documents/PlotWeather/json/todayImage_20240102.json
```

(2) 正常レスポンスファイルを読み込みJavaスクリプト

```com.dreamexample.android.weatherviewer.ReadGetDayImageResponseJson.java
package com.dreamexample.android.weatherviewer;

import com.google.gson.Gson;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

import com.dreamexample.android.weatherviewer.data.ResponseImageData;
import com.dreamexample.android.weatherviewer.data.ResponseImageDataResult;

import static com.dreamexample.android.weatherviewer.functions.MyLogging.DEBUG_OUT;

public class ReadGetDayImageResponseJson {
    private static final String TAG = "ReadPlotWeatherResponseJson";
    static final String USER_HOME = System.getProperty("user.home");
    static final Path JSON_PATH = Paths.get(USER_HOME, "Documents", "PlotWeather", "json");
    static final String OUTPUT_PATH = Paths.get(
            USER_HOME, "Documents", "output").toString();

    public static void main(String[] args) {
        String jsonName = args[0];
        String jsonFullPath = Paths.get(JSON_PATH.toString(), jsonName).toString();
        List<String> lines = new ArrayList<>();
        try  {
            FileInputStream fis = new FileInputStream(jsonFullPath);
            InputStreamReader inputStreamReader = new InputStreamReader(fis, StandardCharsets.UTF_8);
            try (BufferedReader reader = new BufferedReader(inputStreamReader))  {
                String line;
                while ((line = reader.readLine()) != null) {
                    lines.add(line + '\n');
                }
            }
        } catch (FileNotFoundException e) {
            System.out.println("IOException: " + e.getLocalizedMessage());
            System.exit(1);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        String responseJson = String.join("", lines);
        try {
            Gson gson = new Gson();
            ResponseImageDataResult respObj = gson.fromJson(responseJson, ResponseImageDataResult.class);
            ResponseImageData data = respObj.getData();
            if (data.getRecCount() > 0) {
                byte[] decoded = data.getImageBytes();
                String saveName = jsonName.replace(".json", ".png");
                String saveFilePath = Paths.get(OUTPUT_PATH, saveName).toString();
                try (FileOutputStream out = new FileOutputStream(saveFilePath)) {
                    out.write(decoded);
                    DEBUG_OUT.accept(TAG, String.format("%s を保存しました", saveFilePath));
                } catch (IOException exp) {
                    // No ope
                }
            } else {
                DEBUG_OUT.accept(TAG,"気象データ無し");
            }
        } catch (Exception e) {
            // パースエラー
            System.out.println("Exception: " + e.getLocalizedMessage());
        }

    }
}
```

バイトデータをそのまま保存した画像ファイルは以下の画像になります。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/e513d417-2ecb-9871-bc1d-c62ab2ab3cf2.png">


#### 4-2. 画像取得リクエスト実行

ApplicationとRepositoryクラスを使ったリクエストの実行方法

+ リクエスト前処理
    + Applicationオブジェクト生成 ※ Androidアプリの場合は取得
    + 通信リポジトリクラス生成
    + リクエスパラメータ生成
    + 画像表示領域等のサイズ情報をリクエストヘッダーに設定
+ レスポンス受信処理
    + 正常レスポンス ⇒ 画像ファイルに保存する
※AndroidアプリではImageViewに画像を設定
    + エラーレスポンス ⇒ コンソールにエラー内容を出力
※Androidアプリではステータスビューにウォーニングメッセージを表示
    + リクエスト、レスポンス処理で例外 ⇒ コンソールに例外メッセージを出力
※Androidアプリでは例外メッセージのダイアログを生成し表示する

(1) Androidアプリの実装例

+ 取得したバイトデータを **BitmapFactory.decodeByteArrayメソッドを使って Bitmap オブジェクトを生成します。**

```java
// 画像取得ボタンク押下リスナー定義
private final  View.OnClickListener mBtnClickListener = (view) -> performRequest();

// 画像取得リクエスト実行
private void performRequest() {
    // アクティブなネットワーク取得: (優先) WiFi > モバイル回線
    RequestDevice device = NetworkUtil.getActiveNetworkDevice(requireContext());
    // ボタンの二度押下禁止
    mBtnGet.setEnabled(false);

    WeatherApplication app = (WeatherApplication) requireActivity().getApplication();
    WeatherGraphRepository repos = new WeatherGraphRepository();
    // ネットワーク種別 (Wifi|Mobile) に対応するリクエストURL取得
    String requestUrl = app.getRequestUrls().get(device.toString());
    // ImageViewサイズとDisplayMetrics.densityをリクエストヘッダに追加する
    Map<String, String> headers = app.getRequestHeaders();
    if (headers.containsKey(WeatherApplication.REQUEST_IMAGE_SIZE_KEY)) {
        // キーが存在すれば上書き ※2回目以降のリクエストの場合
        headers.replace(WeatherApplication.REQUEST_IMAGE_SIZE_KEY, imgSize);
    } else {
        // なければ追加 ※初回リクエストの場合
        headers.put(WeatherApplication.REQUEST_IMAGE_SIZE_KEY, imgSize);
    }
    // リクエストパラメータ生成
    String requestParam = makeRequestParam(0);

    repos.makeGetRequest(0, requestUrl, requestParam,
        headers, app.mEexecutor, app.mHandler, (result) -> {
            // ボタン状態を戻す
            mBtnGet.setEnabled(true);

            if (result instanceof Result.Success) {
                // 正常レスポンス受信
                ResponseImageDataResult imageResult =
                        ((Result.Success<ResponseImageDataResult>) result).get();
                ResponseImageData data = imageResult.getData();
                if (data.getRecCount() > 0) {
                    // 観測データ有り
                    byte[] decoded = data.getImageBytes();
                    Bitmap bitmap = BitmapFactory.decodeByteArray(
                        decoded, 0, decoded.length);
                    // imageView に画像表示    
                    mImageView.setImageBitmap(bitmap);
                } else {
                    // 観測データなし
                    // No image画像で imageView を更新
                }
            } else if (result instanceof Result.Warning) {
                // エラーレスポンス受信
                ResponseStatus status =
                        ((Result.Warning<?>) result).getResponseStatus();
                // ウォーニングビューに表示
            } else if (result instanceof Result.Error) {
                // リクエストまたはレスポンス受信処理で例外発生
                Exception exception = ((Result.Error<?>) result).getException();
                // 例外メッセージをダイアログに表示
            }
        });
}
```

(2) Javaアプリの実装例

+ 取得したバイトデータを **FileOutputStream で画像ファイルに保存します。**
※ Javaアプリではクリーンアップで ExecutorServiceのシャットダウンメソッドの呼び出しが必要です 

```java
public class MainGetDayImage {
    public static void main(String[] args) {
       // 実行時の引数に、デバイス名, 検索日, '幅x高さx密度' 順で設定する
       // (1) デバイス名
       String deviceName = args[0];
       // (2) 検索日: ISO8601形式 (例) 2023-12-31
       String findDate = args[1];
       // (3) 画像領域サイズ (例) '1064x1680x2.5'
       String argPhoneImageSize = args[2];
    
       WeatherApplication app = new WeatherApplication();
       WeatherGraphRepository repos = new WeatherGraphRepository();
       // ローカルネットワーク(Wi-Fi)のリクエストURL取得
       String requestUrl = app.getRequestUrls().get("wifi");
       // リクエストヘッダーに画像領域サイズを設定
       Map<String, String> headers = app.getRequestHeaders();
       headers.put(WeatherApplication.REQUEST_IMAGE_SIZE_KEY, argImageSize);
       // リクエストパラメータ: '/<device_name>/<find_date>'
       String requestParam = String.format("/%s/%s", deviceName, findDate);
       try {
            repos.makeGetRequest(0, requestUrl, requestParam, headers,
                    app.mEexecutor, app.mHandler, (result) -> {
                if (result instanceof Result.Success) {
                    // 正常レスポンス受信
                    ResponseImageDataResult imageResult =
                            ((Result.Success<ResponseImageDataResult>) result).get();
                    ResponseImageData data = imageResult.getData();
                    if (data.getRecCount() > 0) {
                        byte[] decoded = data.getImageBytes();
                        // 画像データ保存処理
                    } else {
                        // 画像データなし
                    }
                } else if (result instanceof Result.Warning) {
                    // エラーレスポンス受信
                    ResponseStatus status =
                            ((Result.Warning<?>) result).getResponseStatus();
                    // ウォーニング表示処理
                } else if (result instanceof Result.Error) {
                    // リクエストまたはレスポンス受信処理で例外発生
                    Exception exception = ((Result.Error<?>) result).getException();
                    // ウォーニング表示処理
                }
            });
        } finally {
            // Javaアプリでは一回きりの実行なのでシャットダウンでプロセスを終了させる
            app.mEexecutor.shutdownNow();
        }
     }
```

### 5. 結論

　今回は非常に簡単なサンプルで紹介しましたが、本格的なアプリでは通信クラス数も相当の数になります。

　通信部品クラスの数が増えれば増えるほど、これらのクラスはJavaプロジェクトで作成したほうが効率的です。

今回紹介したクラスの完全なソースコードは下記にて公開しています。

[GitHub pipito-yukio/qiita-posts JavaApps/SamplePlotWeatherForJava](https://github.com/pipito-yukio/qiita-posts/tree/main/JavaApps/SamplePlotWeatherForJava)

全ソースリスト (SamplePlotWeatherForJavaプロジェクト)

```
src
├── android
│   ├── app
│   │   └── Application.java
│   ├── content
│   │   └── res
│   │       └── AssetManager.java
│   ├── os
│   │   ├── Handler.java
│   │   └── Looper.java
│   └── util
│       └── Log.java
├── androidx
│   └── core
│       └── os
│           └── HandlerCompat.java
├── com
│   └── dreamexample
│       └── android
│           └── weatherviewer
│               ├── BuildConfig.java        # Androidアプリでは自動生成されるクラス
│               ├── MainGetDayImage.java    # Javaメインアプリケーション(HTTPリクエスト)
│               ├── ReadGetDayImageResponseJson.java # Javaメインアプリケーション(JSONファイル)
│               ├── WeatherApplication.java
│               ├── data
│               │   ├── ResponseImageData.java
│               │   ├── ResponseImageDataResult.java
│               │   ├── ResponseStatus.java
│               │   └── ResponseWarningStatus.java
│               ├── functions
│               │   └── MyLogging.java
│               └── tasks
│                   ├── RepositoryCallback.java
│                   ├── Result.java
│                   ├── WeatherGraphRepository.java
│                   ├── WeatherImageRepository.java
│                   └── WeatherRepository.java
└── resources
    └── request_info.json
```