Help us understand the problem. What is going on with this article?

DjangoとJava for Androidで手書き数字を判定するだけのサーバーを立てて判定するアプリを作った。②クライアント側編 【Androidのマルチーパート送信】

More than 1 year has passed since last update.

1 はじめに

keyword: Django Apache wsgi Ubuntu Android keras tensorflow MNIST VGG16

1.1 経緯

僕は常頃からAndroidメインでやっている人なんですが、サーバーとかに関するもの(いわゆるオープン系)は触ったことないのでやってみようと思ったのが最初です。そこからhttpとかポートとかPOSTとGETとかいろいろ覚えて、何かいいものないかなと言うことで「アプリ側はAndroid、サーバー側はPython」とかいうのを思いついてしまった次第です。結構頑張って書いた記事なので既読感覚でいいねをしてもらっていいですか!!よろしくお願いしまーす。

1.2 どんな感じか

QiitaにAndroidを載せるのは初ですね。今回はAndroidのことは最低限わかっていると仮想して説明したいと思います。設計思想は以下の通りです。

  1. Canvasに数字を書かせる
  2. 送信ボタンを押す。
  3. CanvasをBitmapに変換、縦横比1:1(1000:1000)に変換
  4. サーバーに送信

という簡単なものです。以下の三つのものが必要です。

  1. メインのActivity (Lobby.xmlLobby.java)
  2. マルチパート送信をするClass (PostPngAsyncHttpServer.java)
  3. お絵描きができるCanvas (HandwrittenDigitsCanvas.java)

では実装していきましょう。

2 実装

2.1 HandwrittenDigitsCanvas

お絵描きができるCanvasを備えたViewです。

HandwrittenDigitsCanvas.java
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

/**
 * Created by 博ノ助 on 2018/12/21.
 */

public class HandwrittenDigitsCanvas extends View {
    //メンバ変数
    private Paint paint;
    private Path path;

    public HandwrittenDigitsCanvas (Context context) {
        super(context);
        init(context);
    }

    public HandwrittenDigitsCanvas (Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public HandwrittenDigitsCanvas (Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    //最初に呼ばれる
    private void init(Context context){
        // お絵描きの準備
        path = new Path();
        paint = new Paint();
        paint.setColor(0xFF000000); //線の色
        paint.setStyle(Paint.Style.STROKE); //線のスタイル
        paint.setStrokeJoin(Paint.Join.ROUND); //書き方
        paint.setStrokeCap(Paint.Cap.ROUND); //書き方
        paint.setStrokeWidth(50); //線の太さ
    }

    //Cnavasで最初に呼ばれる
    @Override
    protected void onDraw(Canvas canvas) {
        // 背景を白で塗りつぶす。そうするとPNGにしたときに背景が透過されない
        canvas.drawColor(Color.WHITE); 
        // お絵描きを呼び出す。
        canvas.drawPath(path, paint);
    }

    // Canvasがタップされた時
    @Override
    public boolean onTouchEvent(MotionEvent event){
        float x = event.getX();
        float y = event.getY();

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: // 画面に触ったとき
                path.moveTo(x, y);
                invalidate();
                break;
            case MotionEvent.ACTION_MOVE: // そのままスライドしたとき
                path.lineTo(x, y);
                invalidate();
                break;
            case MotionEvent.ACTION_UP: // 画面から離れたとき
                path.lineTo(x, y);
                invalidate();
                break;
        }
        return true;
    }

    // 線を消去
    public void clear(){
        path.reset();
        invalidate();
    }

    // CanvasをBitmapに変換
    public Bitmap saveCanvasToBitmap(){
        Bitmap bitmap = Bitmap.createBitmap(this.getWidth(), this.getHeight(), Bitmap.Config.ARGB_8888);
        Canvas saveCanvas = new Canvas(bitmap);
        this.draw(saveCanvas);

        return bitmap;
    }
}

特に問題はないと思います。

2.2 PostPngAsyncHttpServer

受け取ったパラメーターを解読してデータとしてぶっこみます。

【コラム】 マルチパートPOSTを理解しよう。

マルチパートとは何ぞや。という方に向けてです。これを理解しておかないとなんじゃこれとなります。

マルチパートとはPOSTの一つです。サーバーへのアクセスの一つです。しかし、マルチパートは通常、文字列や数字しか送れないところをファイルを同封して送ることができます。もちろんバイナリにはなりますけどね。マルチパート送信にはある規則にのっとった文書を送る必要があります。そのテンプレートは以下の通りです。

POST index.py HTTP/192.xxx.x.x\r\n
Host: Cyberhac.com\r\n
Content-Type: multipart/form-data; boundary=wbvb2ifbob2fbu5655642qnscqvcqufbqbfo\r\n
\r\n
--wbvb2ifbob2fbu5655642qnscqvcqufbqbfo\r\n
Content-Disposition: form-data; name="statas"\r\n
Content-Type: text/plain; charset="UTF-8"\r\n
\r\n
fire\r\n
--wbvb2ifbob2fbu5655642qnscqvcqufbqbfo\r\n
Content-Disposition: form-data; name="love?"\r\n
Content-Type: text/plain; charset="UTF-8"\r\n
\r\n
Yes!\r\n
--wbvb2ifbob2fbu5655642qnscqvcqufbqbfo\r\n
Content-Disposition: form-data; name="filename"; filename="text.txt"\r\n
Content-Type: application/octet-stream\r\n
Content-Transfer-Encoding: binary\r\n
\r\n
bucewbfoeqbpfbbfubqbwfpoqnwfciqcbr823gtchm tybvq8cy89xty82tqu
・・・・
・・・・
--wbvb2ifbob2fbu5655642qnscqvcqufbqbfo--\r\n

こんな感じです。
1~2行目は無視して大丈夫です。3行目でこれはマルチパート送信ですよというのとバウンダリーを定義します。バウンダリーとはキーみたいなもので一つのマルチパート送信は一つのバウンダリーを用意しなければいけません。あらかじめ定義してあったバウンダリーを見つけたシステムはこれをデータの開始や終了と解釈します。上のバウンダリーは例です。マルチパート送信ではファイルとともに小さなデータ、つまり文字列や数字をtext/plainとして送ることができます。これをFieldといいます。上ではtext.txt以外にstataslove?という名前(key)の文字列を送っています。それぞれのデータはfireYes!だそうです。まとめると

マルチパート送信のテンプレート
XXXXXXXXXXXXXXXXXXXXXXXXXX
XXXXXXXXXXXXXX //無視
Content-Type: multipart/form-data; boundary=自分で決めるバウンダリー\r\n
\r\n
--自分で決めるバウンダリー\r\n
//なんか送れる
--自分で決めるバウンダリー\r\n
//なんか送れる
--自分で決めるバウンダリー\r\n
//ファイルの基本データ
\r\n
ファイルのバイナリ
--自分で決めるバウンダリー--\r\n

とすれば送れます。//なんか送れるの部分を増やしたければバウンダリーでかこってもう一つ作りましょう。

Fieldの開始と終了は--自分で決めるバウンダリー\r\nで、先頭に「--」が付きます。マルチパート送信の終了は先頭と後ろに「--」をつけましょう。

今回はFieldは要りませんが、必要になった時のためにそう言った関数も作りましょう。

コード

Parameters.java
import android.graphics.Bitmap;

/**
 * Created by micha on 2018/12/26.
 */

public class Parameters {
    public String uri;
    public Bitmap bmp;
    public Parameters (String uri, Bitmap bmp) {
        this.uri = uri;
        this.bmp = bmp;
    }
}
PostPngAsyncHttpServer.java
import android.graphics.Bitmap;
import android.os.AsyncTask;
import android.util.Xml;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Random;

/**
 * Created by micha on 2018/12/25.
 */

public class PostPngAsyncHttpServer extends AsyncTask<Parameters, Void, String> {

    private Listener listener;
    private static final String ENTER_STRING = "\r\n";

    private static final Random r = new Random();
    private static final int n = r.nextInt(100000) + 1;
    private static final String boundary = n + "lkjhcxzfhjclsfn___jkbwejpcipoei2rt28yuxmu38yb%$WER&IHBVCTDR)POIhg8h90u4guhhgbhuhgiuthbiuv8gb" + System.currentTimeMillis() + "cndajbgFVUIOFD%TOihbguesghoauwgboa9vny" + n;

    private static final String CHARSET = Xml.Encoding.UTF_8.name();
    // 非同期処理
    @Override
    protected String doInBackground(Parameters... parameters) {

        Parameters params = parameters[0];

        HttpURLConnection httpConn = null;

        StringBuilder sb = new StringBuilder();

        String urlSt = params.uri;

        String result = null;

        try {

            // URL設定
            URL url = new URL(urlSt);

            // HttpURLConnection
            httpConn = (HttpURLConnection) url.openConnection();

            // request POST
            httpConn.setRequestMethod("POST");

            // no Redirects
            httpConn.setInstanceFollowRedirects(false);

            //キャッシュは使わない
            httpConn.setUseCaches(false);

            // データを書き込む
            httpConn.setDoOutput(true);

            //ヘッダーを書く
            httpConn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);

            //データを書き出す
            httpConn.setDoInput(true);

            // 時間制限
            httpConn.setReadTimeout(10000);
            httpConn.setConnectTimeout(20000);

            // 接続
            httpConn.connect();

            // POSTデータ送信処理
            PrintStream printStream = null;

            try {
                printStream = new PrintStream(httpConn.getOutputStream(), false, CHARSET);
                // addField("status", "Yes, Android!", printStream); statusという名前(key)のデータをいれて中身はYes, Android!という文字列。addFieldの使い方。
                addFile(params.bmp, printStream);
                printStream.print("--" + boundary + "--");
            } catch (IOException e) {
                // POST送信エラー
                e.printStackTrace();
                result="POST送信エラー";
            } finally {
                if (printStream != null) {
                    printStream.close();
                }
            }

            final int status = httpConn.getResponseCode();
            if (status == HttpURLConnection.HTTP_OK) {
                result = catchData(httpConn, sb);
            }
            else{
                result="status="+String.valueOf(status);
            }



        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (httpConn != null) {
                httpConn.disconnect();
            }
        }
        return result;
    }

    // 非同期処理が終了後、結果をメインスレッドに返す
    @Override
    protected void onPostExecute(String result) {
        super.onPostExecute(result);

        if (listener != null) {
            listener.onSuccess(result);
        }
    }

    public void setListener(Listener listener) {
        this.listener = listener;
    }

    public interface Listener {
        void onSuccess(String result);
    }

    private String catchData(HttpURLConnection connection, StringBuilder sb) {
        try {
            // データを受け取る
            InputStream is = connection.getInputStream();
            BufferedReader reader = new BufferedReader(new InputStreamReader(is, CHARSET));
            String line = "";
            while ((line = reader.readLine()) != null)
                sb.append(line);
            is.close();
        }catch (IOException e){
            e.printStackTrace();
        }
        return sb.toString();
    }

    public void addField(String name, String value, PrintStream printStream) {
        printStream.print("--" + boundary);
        printStream.print(ENTER_STRING);
        printStream.print("Content-Disposition: form-data; name=\"" + name + "\"");
        printStream.print(ENTER_STRING);
        printStream.print("Content-Type: text/plain; charset=" + CHARSET);
        printStream.print(ENTER_STRING);
        printStream.print(ENTER_STRING);
        printStream.print(value);
        printStream.print(ENTER_STRING);
        printStream.flush();
    }

    public void addFile(Bitmap image, PrintStream printStream) throws IOException {
        printStream.print("--" + boundary);
        printStream.print(ENTER_STRING);
        printStream.print("Content-Disposition: form-data; name=\"image\"; filename=\"test_" + System.currentTimeMillis() + ".png\"");
        printStream.print(ENTER_STRING);
        printStream.print("Content-Type: " + "application/octet-stream");
        printStream.print(ENTER_STRING);
        printStream.print("Content-Transfer-Encoding: binary");
        printStream.print(ENTER_STRING);
        printStream.print(ENTER_STRING);

        ByteArrayOutputStream bos = null;
        try {
            bos = new ByteArrayOutputStream();
            image.compress(Bitmap.CompressFormat.PNG, 100, bos);
            printStream.write(bos.toByteArray());
        } finally {
            if (bos != null) {
                bos.close();
            }
        }
        printStream.print(ENTER_STRING);
        printStream.flush();
    }
}

ParametersというClassを作ることで中にデータを複数つぎ込むことができます。中には今のところアクセス先のUrlと入れるBitmapのみです。ここにFieldに入れるためのデータを入れても構いません。

PrintStream printStream = null;というのでマルチパート送信するための文書のパイプの口を生成。パイプだけではStringは受け付けないのでパイプの先につける「ろうと」みたいなものです。

printStream = new PrintStream(httpConn.getOutputStream(), false, CHARSET);でパイプに「ろうと」を付けます。httpConnにはパイプが用意されているので.getOutputStream()でそのパイプを取得。newする時の引数を渡すことでつけてくれます。あとはUTF-8を指定すればOKです。

printStream.print();をしていくことで一行一行しっかりと書かれていきます。定期的にprintStream.flush();をして発射していきましょう。
(printStream.print();しただけではパイプに詰まったままです。これを押して「詰まり」を解消しているのです。)

catchDataでデータを取るようになっています。そのデータはresultとしてメインスレッドに返されます。このメインスレッドというのは、setListenerを呼ぶことで指定できます。

かなり難しいと思うので質問などは下のTwitter等からよろしくお願いいたします。答えられる範囲で答えたいと思います。

ちなみにバウンダリーはSystemの日付や日時から生成しています。オリジナルで構いません。というか変えないとセキュリティ的に危ないです。

もしダメな場合は追記した内容を確認してみてください。一番下です。

1.3 LobbyActivity(mainActivity)

Java

LobbyActivity.java
import android.app.AlertDialog;
import android.graphics.Bitmap;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;

import BasicApiResource.HandwrittenDigitsCanvas;
import BasicApiResource.Parameters;
import BasicApiResource.PostPngAsyncHttpServer;

public class LobbyActivity extends AppCompatActivity {

    private HandwrittenDigitsCanvas mCanvas;

    @Override
    protected void onCreate (Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_lobby);

        mCanvas = findViewById(R.id.handwritten_digits_canvas);

    }

    public void onTouchClear(View v){
        mCanvas.clear();
    }

    public void onTouchTestCanvas(View v){
        // CanvasをBitmapへ
        Bitmap bitmap = mCanvas.saveCanvasToBitmap();
        // 縦横比1:1(1000:1000)にする
        bitmap= Bitmap.createScaledBitmap(bitmap, 1000, 1000, false);

        PostPngAsyncHttpServer postPngAsyncHttpServer = new PostPngAsyncHttpServer();
        postPngAsyncHttpServer.setListener(createListener());
        postPngAsyncHttpServer.execute(new Parameters("http://192.xxx.x.x/", bitmap));
    }

    private PostPngAsyncHttpServer.Listener createListener() {
        return new PostPngAsyncHttpServer.Listener() {
            @Override
            public void onSuccess(String result) {
                // 返事としてresultが返ってくる
                Toast.makeText(LobbyActivity.this, result, Toast.LENGTH_LONG).show(); // Toastとして出力しているのみ
            }
        };
    }

    @Override
    protected void onDestroy() {
        task.setListener(null);
        super.onDestroy();
    }

}

Xml

LobbyActivity.xml
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.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="com.example.micha.internetaiprogram.LobbyActivity"
    tools:layout_editor_absoluteY="81dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:layout_editor_absoluteX="158dp"
        tools:layout_editor_absoluteY="40dp">

        <BasicApiResource.HandwrittenDigitsCanvas
            android:id="@+id/handwritten_digits_canvas"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="8" />

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:orientation="horizontal">

            <Button
                android:id="@+id/button_clear"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:onClick="onTouchClear"
                android:text="Clear" />

            <Button
                android:id="@+id/button_test"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:onClick="onTouchTestCanvas"
                android:text="test" />
        </LinearLayout>
    </LinearLayout>
</android.support.constraint.ConstraintLayout>

android:onClick=""でxmlに直接タップして時の処理を選ぶことができます。必ず引数はView出ないといけませんが。こんな感じになると思います。
2019-01-03.png

下のようになると思います。(DU RECORDERですみません...)
vzfv3-2vaat.gif

まとめ

今回は前回の続きということでAndroidでマルチパート送信をやってみました!手書き数字を判定するだけではなくマルチパート送信を使うときには上のソースコードを使っていただければ幸いです。難しい部分もあると思うので、質問はどうぞTwitterまで。ではまた。

Twitter: https://twitter.com/Cyber_Hacnosuke (フォローしてくださいお願いします。)
Github: https://github.com/CyberHacnoshuke

【追記:投稿直後】

サーバーによってはハッキングや不正な連続アクセスのアタック(DOS攻撃)から守るためにCSRF対策が有効になってる場合があります。一般的にはHTMLがしっかり認証してくれるそうですが、Androidなどからアクセスするともちろん跳ね返されます。
セキュリティ面では危ないですがCSRF対策をオフにしましょう。フレームワークごとに違いますが、Djangoではメソッド前に@csrf_exemptを付けることで解除できそうです。(前の記事にはしれっと書いてある)なお、メソッドごとにしか効かないのでプロジェクトごと無効にしたいのであれば別の方法でできます。(すみません、調べてません)
CSRFについて知りたい方はどうぞ↓
https://qiita.com/maruloop/items/e14d02299bd136f4b1fc

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away