Androidのホーム画面にウィジェットを配置しよう

（今年一回目の投稿です）

今回はAndroidのホーム画面に自分カスタムのウィジェットを配置します。
カスタムといっても、自分の好きなWebAPIを呼び出すだけモノのです。
ですが、サーバを立ち上げてWebAPIの呼び出し先での後処理をカスタマイズすれば、なんでもできますよね。
ということで、ウィジェットを配置するAndroidアプリケーションと、WebAPI呼び出しを受け付けるNode.jsサーバの2本立てです。

ソースコードもろもろは以下のGitHubに上げてあります。

poruruba/CustomWidget

https://github.com/poruruba/CustomWidget

# 全体構成

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f9278efc-6f24-2878-d212-518d31cb0cf0.png)

ウィジェットのためのAndroidアプリケーションと、WebAPI呼び出しを受け付けるNode.jsサーバの2つからなります。
Node.jsサーバは、ウィジェットからのタップイベントを受け取るWebAPIサーバの機能と、タップイベントに対応する任意の処理を設定するためのWebページの2つの機能があります。

任意の処理の事前設定は、正確には少々説明が必要です。
正確には以下の順番です。

１．	Androidアプリをインストールし、起動します。そうすると、アプリインスタンスごとにユニークなUUIDを生成します。
２．	Androidからウィジェットを追加します。そうするとウィジェットごとにウィジェットIDが払い出されます。
３．	ウィジェットをタップします。そうすると、Node.jsサーバのWebAPIが呼び出され、UUIDとウィジェットIDがサーバに通知されます。この時点では、任意の処理が設定されていないので、何も起こりません。
４．	ブラウザからWidget管理コンソールのページを開きます。そうすると、先ほど登録されたUUIDのウィジェットIDがあることがわかります。
５．	Widget管理コンソールから、ウィジェットIDに対応した任意の処理を設定します。今回は、他の任意のサイトへのWebAPI呼び出し(HTTP Post JSON)のURLとBodyに指定するJSONを設定できるようにしました。
６．	あらためて、ウィジェットをタップします。そうすると、Node.jsサーバのWebAPIが呼び出されUUIDとウィジェットIDがサーバに通知されます。
７．	サーバ側では、ウィジェットIDに対応した任意の処理を実行します。今回は設定した他の任意のサイトへのWebAPI呼び出しをJSONとともにします。

なので、忘れずにAndroidManifest.xmlに以下を追記しておきます。

```xml:Android\CustomWidget\app\src\main\AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
```

# ウィジェット機能の追加

ウィジェットを置けるようになるには、Androidアプリケーションに以下を追加します。

・ウィジェットのレイアウトXMLを作成
・ウィジェットを追加するためのレシーバクラスを作成
・AndroidManifest.xmlに、レシーバの設定とウィジェット情報を設定

上記でウィジェットが置けるようになるのですが、今回は、ウィジェットを置くときにユーザが任意の設定ができるように、そのためのアクティビティを追加します。ウィジェットごとに同じ設定とする場合は不要です。

ちなみに、以降一から作る想定で説明していきますが、Android Studioには、Widgetを追加するためのウィザードがあるので、それを使うのも手です。

## ウィジェットのレイアウトXML

通常のレイアウトと同様に、XMLで定義します。
ただし、使えるコンポーネントは限定されていますので、単純な構成にします。

https://developer.android.com/guide/topics/appwidgets?hl=ja#CreatingLayout

今回はこんな感じで作りました。


```xml:Android\CustomWidget\app\src\main\res\layout\custom_app_widget.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_gravity="center"
    android:gravity="center"
    android:background="#00ff00"
    android:id="@+id/appwidget_container">

    <TextView
        android:id="@+id/appwidget_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:fontFamily="@font/pottaone_regular"
        android:text="●"
        android:textSize="24sp"
        android:textColor="#ffffff"
        android:textStyle="bold" />
</LinearLayout>
```

このうち、LinearLayoutの背景色、TextViewの文字列・文字色・文字サイズは、後程作成するウィジェット作成ダイアログでユーザが自由に設定できるようにしてます。なので、IDを割り振ってます。
また、フォントも雰囲気を変えたく、以下から持ってきました。

https://fonts.google.com/noto/specimen/Noto+Sans+JP?preview.text=pottaone%20regular&preview.text_type=custom


## ウィジェットを追加するためのレシーバクラスを作成

ウィジェットを追加するためには、AppWidgetProviderの派生クラスを実装する必要があります。
大事なメソッドは以下の3つです。

・public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds)

ウィジェットを配置するときに呼び出されます。

・public void onReceive(Context context, Intent intent)

ウィジェットの状態が変わったときに呼び出されますが、今回は特に、ウィジェットをタップしたときの処理を実装します。

・public void onDeleted(Context context, int[] appWidgetIds)

ウィジェットを削除したときに呼び出されます。

それぞれについて補足します。

●onUpdate

ウィジェットを配置しようとしたときに呼び出されます。配置しようとしているウィジェットを識別するためのウィジェットIDが渡ってきてます。
最終的には、以下を呼び出すことで、ウィジェットとして配置が完了します。

appWidgetManager.updateAppWidget(appWidgetId, views);

その前に以下の2つのことをする必要があります。

・ウィジェットをタップしたときのトリガを設定
　views.setOnClickPendingIntent を呼び出して、タップされたときにPendingIntentが発行されるようにします。PendingIntentが渡された先で後処理をするため、ウィジェットIDをputExtraで取り出せるようにしています(表示用にタイトル文字列も)。
ちなみに、Android OS 8から多少ここら辺の仕様が変わったようで、Minimumを8にしています。

・ウィジェットの表示のカスタム設定
ウィジェットのレイアウトXMLの定義をベースに、LinearLayoutの背景色、TextViewの文字列・文字色・文字サイズを変更します。実は後程説明する設定用Activityでユーザが独自の指定を選択した後にこの関数が呼ばれますので、その時指定された値を取り出してウィジェットに設定しているわけです。そのやり取りのために、SharedPreferenceを使ってます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/273c47e3-f8b2-2a37-4128-4f49340a1677.png)

```java:Android\CustomWidget\app\src\main\java\jp\or\myhome\sample\customwidget\CustomAppWidget.java
    static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId) {
        Log.d(TAG, "updateAppWidget called");

        RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.custom_app_widget);
        try{
            JSONObject json = WidgetConfigureActivity.loadPreference(context, appWidgetId);

            Intent clickIntent = new Intent(context, CustomAppWidget.class);
            clickIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
            clickIntent.putExtra("title_text", json.getString("title_text"));
            clickIntent.setAction(MainActivity.ACTION_NAME);
            PendingIntent pendingIntent = PendingIntent.getBroadcast(context, appWidgetId, clickIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
            views.setOnClickPendingIntent(R.id.appwidget_container, pendingIntent);

            views.setTextViewText(R.id.appwidget_text, json.getString("title_text"));
            views.setTextColor(R.id.appwidget_text, json.getInt("title_color"));
            views.setTextViewTextSize(R.id.appwidget_text, TypedValue.COMPLEX_UNIT_SP, json.getInt("title_size"));
            views.setInt(R.id.appwidget_container, "setBackgroundColor", json.getInt("background_color"));
        }catch(Exception ex){
            Log.d(TAG, ex.getMessage());
        }
        appWidgetManager.updateAppWidget(appWidgetId, views);
    }

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        Log.d(TAG, "update called");

        // There may be multiple widgets active, so update all of them
        for (int appWidgetId : appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId);
        }
    }
```

●onReceive

次は、onReceiveです。ウィジェットの状態が変わるたびに呼んでくれます。

https://developer.android.com/guide/topics/appwidgets?hl=ja#ProviderBroadcasts

が、今回実装するのは、ウィジェットをタップしたときのPendingIntentの処理です。
Node.jsサーバに、UUID・ウィジェットIDとセットでWebAPI呼び出しをしています。

一応、前後でNotificationを入れるようにしています。処理を受け付けたことがわかるためのNotificationと、WebAPI呼び出しに時間がかかることを想定して呼び出し中だけNotificationを表示させてます。

```java:Android\CustomWidget\app\src\main\java\jp\or\myhome\sample\customwidget\CustomAppWidget.java
    @Override
    public void onReceive(Context context, Intent intent) {
        Log.d(TAG, "onReceive() Action: " + intent.getAction());

        String action = intent.getAction();
        Bundle extras = intent.getExtras();
        if(extras != null) {
            int widgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
            Log.d(TAG, "onReceive(), widgetId=" + widgetId);

            if (MainActivity.ACTION_NAME.equals(action) && widgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
                NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
                Thread thread = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            String title = intent.getStringExtra("title_text");
                            NotificationCompat.Builder builder = new NotificationCompat.Builder(context, MainActivity.CHANNEL_ID_PROCESS);
                            builder.setContentTitle("カスタムウィジェット");
                            builder.setContentText("「" + title + "」処理中");
                            builder.setSmallIcon(android.R.drawable.ic_popup_reminder);
                            builder.setAutoCancel(false);
                            notificationManager.notify(widgetId, builder.build());

                            SharedPreferences pref = context.getSharedPreferences(MainActivity.PREF_NAME, Context.MODE_PRIVATE);
                            String uuid = pref.getString(MainActivity.PREF_UUID, null);
                            String base_url = pref.getString(MainActivity.PREF_BASEURL, null);
                            JSONObject request = new JSONObject();
                            request.put("uuid", uuid);
                            request.put("widget_id", widgetId);
                            JSONObject response = HttpPostJson.doPost(base_url + "/widget-call", request, MainActivity.DEFAULT_TIMEOUT);
                            Log.d(TAG, "HttpPostJson.doPost OK");

                            NotificationCompat.Builder builder2 = new NotificationCompat.Builder(context, MainActivity.CHANNEL_ID_FINISH);
                            builder2.setContentTitle("カスタムウィジェット");
                            builder2.setContentText("「" + title + "」の処理完了");
                            builder2.setSmallIcon(android.R.drawable.ic_popup_reminder);
                            builder2.setAutoCancel(true);
                            notificationManager.notify(0, builder2.build());
                        } catch (Exception e) {
                            e.printStackTrace();
                        } finally {
                            notificationManager.cancel(widgetId);
                        }
                    }
                });
                thread.start();
            }
        }

        super.onReceive(context, intent);
    }
```

●onDelete

ウィジェットがホーム画面から削除されたときに呼び出されます。
サーバ側にもウィジェットIDに紐づけて情報を管理しているため、それを削除するためにこれを使ってます。

## AndroidManifest.xmlに、レシーバの設定とウィジェット情報を設定

以下のように、レシーバの設定をしています。ほぼお決まりです。

```xml:Android\CustomWidget\app\src\main\AndroidManifest.xml
        <receiver
            android:name=".CustomAppWidget"
            android:exported="true">
            <intent-filter>
                <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
            </intent-filter>

            <meta-data
                android:name="android.appwidget.provider"
                android:resource="@xml/custom_app_widget_info" />
        </receiver>
```

https://developer.android.com/guide/topics/appwidgets?hl=ja#Manifest

ウィジェット情報は、custom_app_widget_info.xml に記述しています。

```xml:Android\CustomWidget\app\src\main\res\xml\custom_app_widget_info.xml
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:configure="jp.or.myhome.sample.customwidget.WidgetConfigureActivity"
    android:initialKeyguardLayout="@layout/custom_app_widget"
    android:initialLayout="@layout/custom_app_widget"
    android:minWidth="40dp"
    android:minHeight="40dp"
    android:previewLayout="@layout/custom_app_widget"
    android:resizeMode="horizontal|vertical"
    android:targetCellWidth="1"
    android:targetCellHeight="1"
    android:widgetCategory="home_screen" />
```

android:configure があると、ウィジェットをホーム画面に配置する前に、この設定用のアクティビティが呼び出されるようになります。

https://developer.android.com/guide/topics/appwidgets?hl=ja#MetaData

# 設定用アクティビティ

こんな画面のアクティビティです。
ホーム画面に配置した際のタイトルを任意の文字に設定できるようにしています。ほかにも、文字色、背景色、文字列の大きさを指定できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/98eba9ec-cd1c-0413-dcc4-a579b4e8482a.png)

この中でやっていることは、指定された値をSharedPreferenceに保存することです。
ウィジェットIDをキーとして保存します。

カラーピッカーには以下を使わせていただきました。非常に助かりました。

https://github.com/jaredrummler/ColorPicker

```java:Android\CustomWidget\app\src\main\java\jp\or\myhome\sample\customwidget\WidgetConfigureActivity.java
package jp.or.myhome.sample.customwidget;

import androidx.appcompat.app.AppCompatActivity;
import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.SeekBar;
import android.widget.TextView;
import android.widget.Toast;
import org.json.JSONObject;
import com.jaredrummler.android.colorpicker.ColorPickerDialog;
import com.jaredrummler.android.colorpicker.ColorPickerDialogListener;

public class WidgetConfigureActivity extends AppCompatActivity implements View.OnClickListener, ColorPickerDialogListener, SeekBar.OnSeekBarChangeListener {
    public static final String TAG = MainActivity.TAG;
    JSONObject json;
    int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;

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

        setResult(RESULT_CANCELED);

        Intent intent = getIntent();
        Bundle extras = intent.getExtras();
        if (extras != null)
            mAppWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
        if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
            finish();
            return;
        }

        try{
            json = loadPreference(this, mAppWidgetId);
            EditText edit;
            edit = (EditText)findViewById(R.id.edit_config_text);
            edit.setText(json.getString("title_text"));
            TextView text;
            int color;
            text = (TextView) findViewById(R.id.txt_config_text_color);
            color = json.getInt("title_color");
            text.setText("#" + String.format("%08X", color));
            text.setTextColor(color);
            text = (TextView) findViewById(R.id.txt_config_background_color);
            color = json.getInt("background_color");
            text.setText("#" + String.format("%08X", color));
            text.setTextColor(color);
            int size = json.getInt("title_size");
            SeekBar seek;
            seek = (SeekBar)findViewById(R.id.seek_config_text_size);
            seek.setProgress(size);
            updateFontImage();
        }catch(Exception ex){
            Log.d(TAG, ex.getMessage());
        }

        Button btn;
        btn = (Button)findViewById(R.id.btn_config_add);
        btn.setOnClickListener(this);
        btn = (Button)findViewById(R.id.btn_config_text_color);
        btn.setOnClickListener(this);
        btn = (Button)findViewById(R.id.btn_config_background_color);
        btn.setOnClickListener(this);
        SeekBar seek;
        seek = (SeekBar)findViewById(R.id.seek_config_text_size);
        seek.setOnSeekBarChangeListener(this);
    }

    public static JSONObject loadPreference(Context context, int appWidgetId) throws Exception{
        SharedPreferences pref = context.getSharedPreferences(MainActivity.PREF_NAME, Context.MODE_PRIVATE);
        String jsonString = pref.getString(MainActivity.PREF_ID_PREFIX + appWidgetId, null);
        if (jsonString == null) {
            JSONObject json = new JSONObject();
            json.put("title_text", "");
            json.put("title_color", Color.WHITE);
            json.put("background_color", Color.GREEN);
            json.put("title_size", 24);
            return json;
        } else {
            return new JSONObject(jsonString);
        }
    }

    public static void savePreference(Context context, int mAppWidgetId, JSONObject json){
        SharedPreferences pref = context.getSharedPreferences(MainActivity.PREF_NAME, Context.MODE_PRIVATE);
        SharedPreferences.Editor editor = pref.edit();
        editor.putString(MainActivity.PREF_ID_PREFIX + mAppWidgetId, json.toString());
        editor.apply();
    }

    @Override
    public void onClick(View view) {
        switch(view.getId()){
            case R.id.btn_config_add:{
                try {
                    EditText edit;
                    edit = (EditText) findViewById(R.id.edit_config_text);
                    String title = edit.getText().toString();

                    json.put("title_text", title);
                    savePreference(this, mAppWidgetId, json);

                    new ProgressAsyncTaskManager.Callback( this, "通信中です。", null )
                    {
                        @Override
                        public Object doInBackground(Object obj) throws Exception {
                            SharedPreferences pref = getSharedPreferences(MainActivity.PREF_NAME, Context.MODE_PRIVATE);
                            String uuid = pref.getString(MainActivity.PREF_UUID, "");
                            String base_url = pref.getString(MainActivity.PREF_BASEURL, "");
                            JSONObject request = new JSONObject();
                            request.put("uuid", uuid);
                            request.put("widget_id", mAppWidgetId);
                            request.put("title", title);
                            request.put("model", Build.MODEL);
                            JSONObject response = HttpPostJson.doPost(base_url + "/widget-add", request, MainActivity.DEFAULT_TIMEOUT);
                            Log.d(TAG, "HttpPostJson OK");
                            return response;
                        }

                        @Override
                        public void doPostExecute(Object obj) {
                            if (obj instanceof Exception) {
                                Toast.makeText(getApplicationContext(), ((Exception)obj).getMessage(), Toast.LENGTH_LONG).show();
                                return;
                            }

                            JSONObject response = (JSONObject)obj;
                            AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(getApplicationContext());
                            CustomAppWidget.updateAppWidget(getApplicationContext(), appWidgetManager, mAppWidgetId);

                            Intent resultValue = new Intent();
                            resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId);
                            setResult(RESULT_OK, resultValue);
                            finish();
                        }
                    };
                }catch(Exception ex){
                    Log.d(TAG, ex.getMessage());
                }
                break;
            }
            case R.id.btn_config_text_color:{
                try {
                    ColorPickerDialog.newBuilder()
                            .setDialogTitle(R.string.color_select_description)
                            .setDialogType(ColorPickerDialog.TYPE_CUSTOM)
                            .setAllowPresets(false)
                            .setDialogId(R.id.btn_config_text_color)
                            .setColor(json.getInt("title_color"))
                            .setShowAlphaSlider(false)
                            .show(this);
                }catch(Exception ex){
                    Log.d(TAG, ex.getMessage());
                }
                break;
            }
            case R.id.btn_config_background_color:{
                try {
                    ColorPickerDialog.newBuilder()
                            .setDialogTitle(R.string.color_select_description)
                            .setDialogType(ColorPickerDialog.TYPE_CUSTOM)
                            .setAllowPresets(false)
                            .setDialogId(R.id.btn_config_background_color)
                            .setColor(json.getInt("background_color"))
                            .setShowAlphaSlider(true)
                            .show(this);
                }catch(Exception ex){
                    Log.d(TAG, ex.getMessage());
                }
                break;
            }
        }
    }

    @Override
    public void onColorSelected(int dialogId, int color) {
        Log.d(TAG, "onColorSelected() called with: dialogId = [" + dialogId + "], color = [" + color + "]");
        switch (dialogId) {
            case R.id.btn_config_text_color: {
                try {
                    json.put("title_color", color);
                    TextView text;
                    text = (TextView) findViewById(R.id.txt_config_text_color);
                    text.setText("#" + String.format("%08X", color));
                    text.setTextColor(color);
                    updateFontImage();
                }catch(Exception ex){
                    Log.d(TAG, ex.getMessage());
                }
                break;
            }
            case R.id.btn_config_background_color: {
                try {
                    json.put("background_color", color);
                    TextView text;
                    text = (TextView) findViewById(R.id.txt_config_background_color);
                    text.setText("#" + String.format("%08X", color));
                    text.setTextColor(color);
                    updateFontImage();
                }catch(Exception ex){
                    Log.d(TAG, ex.getMessage());
                }
                break;
            }
        }
    }

    private void updateFontImage(){
        try{
            int progress = json.getInt("title_size");
            int foreColor = json.getInt("title_color");
            int backColor = json.getInt("background_color");
            TextView text;
            text = (TextView)findViewById(R.id.txt_config_text_size);
            text.setText(String.valueOf(progress) + "sp");
            text.setTextSize(progress);
            text.setTextColor(foreColor);
            text.setBackgroundColor(backColor);
        }catch(Exception ex){
            Log.d(TAG, ex.getMessage());
        }
    }

    @Override
    public void onDialogDismissed(int dialogId) {
        Log.d(TAG, "onDialogDismissed() called with: dialogId = [" + dialogId + "]");
    }

    @Override
    public void onProgressChanged(SeekBar seekBar, int i, boolean b) {
        try {
            json.put("title_size", i);
            updateFontImage();
        }catch(Exception ex){
            Log.d(TAG, ex.getMessage());
        }
    }

    @Override
    public void onStartTrackingTouch(SeekBar seekBar) {

    }

    @Override
    public void onStopTrackingTouch(SeekBar seekBar) {

    }
}
```

また、AndroidManifest.xmlにも、このアクティビティの設定において、特別なインテントを記載しておきます。

```xml:Android\CustomWidget\app\src\main\AndroidManifest.xml
        <activity
            android:name=".WidgetConfigureActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
            </intent-filter>
        </activity>
```

# MainActivity

一方のMainActivityは必須ではありませんが、Node.jsサーバのURLを指定するために使いました。
Notificationのチャネル設定もここでしておきました。

```java:Android\CustomWidget\app\src\main\java\jp\or\myhome\sample\customwidget\MainActivity.java
package jp.or.myhome.sample.customwidget;

import androidx.appcompat.app.AppCompatActivity;
import android.app.AlertDialog;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.TextView;
import java.util.UUID;

public class MainActivity extends AppCompatActivity implements View.OnClickListener {
    public static final String TAG = "LogTag";
    public static final String PREF_NAME = "Private";
    public static final String PREF_BASEURL = "customwidget_baseurl";
    public static final String PREF_UUID = "customwidget_uuid";
    public static final String PREF_ID_PREFIX = "customwidget_id_";
    public static final String CHANNEL_ID_PROCESS = "widget_processing";
    public static final String CHANNEL_ID_FINISH = "widget_finished";
    public static final String ACTION_NAME = "customwidget_doAction";
    public static final String DEFAULT_BASE_URL = "https://【Node.jsサーバのURL】";
    public static final int DEFAULT_TIMEOUT = 10000;
    SharedPreferences pref;
    String uuid;
    String base_url;

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

        pref = getSharedPreferences(PREF_NAME, MODE_PRIVATE);
        base_url = pref.getString(PREF_BASEURL, null);
        if( base_url == null ) {
            base_url = DEFAULT_BASE_URL;
            SharedPreferences.Editor editor = pref.edit();
            editor.putString(PREF_BASEURL, base_url);
            editor.apply();
        }
        uuid = pref.getString(PREF_UUID, null);
        if( uuid == null ) {
            uuid = UUID.randomUUID().toString();
            SharedPreferences.Editor editor = pref.edit();
            editor.putString(PREF_UUID, uuid);
            editor.apply();
        }

        NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
        NotificationChannel channel = new NotificationChannel(CHANNEL_ID_PROCESS,"処理中チャネル", NotificationManager.IMPORTANCE_DEFAULT);
        notificationManager.createNotificationChannel(channel);
        NotificationChannel channel2 = new NotificationChannel(CHANNEL_ID_FINISH,"処理完了チャネル", NotificationManager.IMPORTANCE_DEFAULT);
        notificationManager.createNotificationChannel(channel2);

        TextView text;
        text = (TextView)findViewById(R.id.txt_config_uuid);
        text.setText(uuid);
        text = (TextView)findViewById(R.id.txt_config_model);
        text.setText(Build.MODEL);
        EditText edit;
        edit = (EditText)findViewById(R.id.edit_config_urlbase);
        edit.setText(base_url);
        Button btn;
        btn = (Button)findViewById(R.id.btn_config_update);
        btn.setOnClickListener(this);
        ImageButton imgbtn;
        imgbtn = (ImageButton)findViewById(R.id.imgbtn_view_console);
        imgbtn.setOnClickListener(this);
    }

    @Override
    public void onClick(View view) {
        switch(view.getId()){
            case R.id.btn_config_update:{
                EditText edit;
                edit = (EditText)findViewById(R.id.edit_config_urlbase);
                String base_url = edit.getText().toString();
                SharedPreferences.Editor editor = pref.edit();
                editor.putString(PREF_BASEURL, base_url);
                editor.apply();

                AlertDialog.Builder builder = new AlertDialog.Builder(this);
                builder.setTitle("設定しました。");
                builder.setPositiveButton("閉じる", null);
                builder.create().show();
                break;
            }
            case R.id.btn_config_reset:{
                EditText edit;
                edit = (EditText)findViewById(R.id.edit_config_urlbase);
                edit.setText(base_url);
                break;
            }
            case R.id.imgbtn_view_console:{
                Uri uri = Uri.parse(base_url + "/widget_console");
                Intent intent = new Intent(Intent.ACTION_VIEW,uri);
                startActivity(intent);
                break;
            }
        }
    }
}
```

# Node.jsサーバ

ウィジェットからの呼び出しを受け付けるWebAPIと、ウィジェットIDごとの任意の処理を指定するための管理コンソールの2つの機能を持っています。
ソースコードもろもろおいてますので、詳細は省きます。（すみません）

## WebAPIサーバ

任意の処理の保存にSQLite3を使っています。
Androidからの呼び出しに加えて、管理コンソールのページからの呼び出しもあります。

<details><summary>ソースコード(index.js)</summary><div>

```js:nodejs\api\controllers\widget-api\index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || "/opt/";
const Response = require(HELPER_BASE + 'response');
const Redirect = require(HELPER_BASE + 'redirect');

const sqlite3 = require("sqlite3");
const WIDGET_FILE_PATH = process.env.THIS_BASE_PATH + '/data/widget/widget.db';
const WIDGET_TABLE_NAME = "widget";

const { URL, URLSearchParams } = require('url');
const fetch = require('node-fetch');
const Headers = fetch.Headers;

const db = new sqlite3.Database(WIDGET_FILE_PATH);
db.each("SELECT COUNT(*) FROM sqlite_master WHERE TYPE = 'table' AND name = '" + WIDGET_TABLE_NAME + "'", (err, row) =>{
  if( err ){
    console.error(err);
    return;
  }
  if( row["COUNT(*)"] == 0 ){
    db.run("CREATE TABLE '" + WIDGET_TABLE_NAME + "' (id TEXT PRIMARY KEY, uuid TEXT, widget_id INTEGER, title TEXT, model TEXT, target_url TEXT, payload TEXT)", (err, row) =>{
      if( err ){
        console.error(err);
        return;
      }
    });
  }
});

exports.handler = async (event, context, callback) => {
	var body = JSON.parse(event.body);
	console.log(body);
	
	if( event.path == '/widget-add' ){
		var uuid = body.uuid;
		var widget_id = body.widget_id;
		var title = body.title;
		var model = body.model;
		var id = uuid + '_' + widget_id;
		var result = await new Promise((resolve, reject) =>{
			db.run("INSERT INTO '" + WIDGET_TABLE_NAME + "' (id, uuid, widget_id, title, model) VALUES (?, ?, ?, ?, ?)", [id, uuid, widget_id, title, model], (err) =>{
				if( err )
					return reject(err);
				resolve({});
			});
		});

		return new Response({});
	}else
	if( event.path == '/widget-update' ){
		var uuid = body.uuid;
		var widget_id = body.widget_id;
		var target_url = body.target_url;
		var payload = body.payload;
		var id = uuid + '_' + widget_id;
		var result = await new Promise((resolve, reject) =>{
			db.run("UPDATE '" + WIDGET_TABLE_NAME + "' SET target_url = ?, payload = ? WHERE id = ?", [target_url, payload, id], (err) =>{
				if( err )
					return reject(err);
				resolve({});
			});
		});

		return new Response({});
	}else
	if( event.path == '/widget-delete' ){
		var uuid = body.uuid;
		var widget_id = body.widget_id;
		var id = uuid + '_' + widget_id;
	    var result = await new Promise((resolve, reject) =>{
	      db.all("DELETE FROM '" + WIDGET_TABLE_NAME + "' WHERE id = ?", [id], (err) => {
				if( err )
					return reject(err);
				resolve({});
			});
	    });
		return new Response({});
	}else
	if( event.path == '/widget-call' ){
		var uuid = body.uuid;
		var widget_id = body.widget_id;
		var id = uuid + '_' + widget_id;
	    var item = await new Promise((resolve, reject) =>{
	      db.all("SELECT * FROM '" + WIDGET_TABLE_NAME + "' WHERE id = ?", [id], (err, rows) => {
				if( err )
					return reject(err);
				if( rows.length > 0 )
					resolve(rows[0]);
				else
					reject('id not found');
	      });
	    });
	    console.log(item);
	    
	    if( item.target_url ){
	    	var result = await do_post(item.target_url, JSON.parse(item.payload));
	    	return new Response({ result: result });
	    }else{
			return new Response({});
		}
	}else
	if( event.path == '/widget-list' ){
		var uuid = body.uuid;
	    var rows = await new Promise((resolve, reject) =>{
	      db.all("SELECT * FROM '" + WIDGET_TABLE_NAME + "' WHERE uuid = ?", [uuid], (err, rows) => {
				if( err )
					return reject(err);
				resolve(rows);
	      });
	    });

		return new Response({ rows: rows });
	}else
	if( event.path == '/widget-list-uuid' ){
	    var rows = await new Promise((resolve, reject) =>{
	      db.all("SELECT DISTINCT uuid, model FROM '" + WIDGET_TABLE_NAME + "'", [], (err, rows) => {
				if( err )
					return reject(err);
				resolve(rows);
	      });
	    });

		var list = rows.map(item => {
		 return { uuid: item.uuid, model: item.model };
		});
		return new Response({ rows: list });
	}else
	if( event.path == '/widget-delete-uuid' ){
		var uuid = body.uuid;
	    var result = await new Promise((resolve, reject) =>{
	      db.all("DELETE FROM '" + WIDGET_TABLE_NAME + "' WHERE uuid = ?", [uuid], (err) => {
				if( err )
					return reject(err);
				resolve({});
			});
	    });

		return new Response({});
	}else{
		throw "unknown endpoint";
	}
};

function do_post(url, body) {
  const headers = new Headers({ "Content-Type": "application/json" });

  return fetch(url, {
      method: 'POST',
      body: JSON.stringify(body),
      headers: headers
    })
    .then((response) => {
      if (!response.ok)
        throw new Error('status is not 200');
      return response.json();
    });
}
```
</div></details>

## 管理コンソール

WebAPI側にもろもろ機能が実装されているので、比較的シンプルです。
Bootstrap3とVue2を使い、SPAで実装しています。

こんな感じの画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1d6bc7cf-cc31-4baf-aed6-14abf98bd890.png)

<details><summary>ソースコード(index.html)</summary><div>

```html:nodejs\public\widget_console\index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <meta http-equiv="Content-Security-Policy" content="default-src * data: gap: https://ssl.gstatic.com 'unsafe-eval' 'unsafe-inline'; style-src * 'unsafe-inline'; media-src *; img-src * data: content: blob:;">
  <meta name="format-detection" content="telephone=no">
  <meta name="msapplication-tap-highlight" content="no">
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width">

  <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js" integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ" crossorigin="anonymous"></script>
  <!-- Latest compiled and minified CSS -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
  <!-- Optional theme -->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap-theme.min.css" integrity="sha384-6pzBo3FDv/PJ8r2KRkGHifhEocL+1X2rVCTTkUfGk7/0pbek5mMa1upzvWbrUbOZ" crossorigin="anonymous">
  <!-- Latest compiled and minified JavaScript -->
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js" integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd" crossorigin="anonymous"></script>

  <link rel="stylesheet" href="css/start.css">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/spinkit/2.0.1/spinkit.min.css" />
  <script src="js/methods_bootstrap.js"></script>
  <script src="js/components_bootstrap.js"></script>
  <script src="js/components_utils.js"></script>
  <script src="js/vue_utils.js"></script>
  <script src="js/gql_utils.js"></script>

  <script src="js/remoteconsole.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vconsole/dist/vconsole.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vuex@3.x/dist/vuex.min.js"></script>

  <script src="https://cdn.jsdelivr.net/npm/js-cookie@2/src/js.cookie.min.js"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.css">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/toastr.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.7/dat.gui.min.js"></script>

  <title>Widget管理コンソール</title>
</head>
<body>
<!--
    <div id="loader-background">
      <div class="sk-plane sk-center"></div>
    </div>
-->
    <div id="top" class="container">
      <div class="jumbotron">
        <h2>Widget管理コンソール</h2>
      </div>

        <button class="btn btn-default btn-sm pull-right" v-on:click="delete_uuid_call">削除</button>
        <div class="form-inline">
          <label>model</label> <select class="form-control" v-model="target_item" v-on:change="widget_list_update">
            <option v-for="(item, index) in uuid_list" v-bind:value="item">{{item.model}}</option>
          </select>
          <label>uuid</label> {{target_item.uuid}}
        </div>

        <table class="table table-striped">
          <thead>
            <tr><th>title</th><th>target_url</th><th>payload</th><th>action</th></tr>
          </thead>
          <tbody>
            <tr v-for="(item, index) in widget_list">
              <td>{{item.title}}</td><td>{{item.target_url}}</td>
              <td><button class="btn btn-default btn-sm" v-on:click="show_payload(index)">JSON</button></td>
              <td>
                <button class="btn btn-default btn-sm" v-on:click="update_widget_start(index)">変更</button>
                <button class="btn btn-default btn-sm" v-on:click="delete_widget_call(index)">削除</button>
              </td>
            </tr>
          </tbody>
        </table>

        <div class="modal fade in" id="show_payload_dialog">
          <div class="modal-dialog">
            <div class="modal-content">
              <div class="modal-header">
                <h4 class="modal-title">Payload(JSON)</h4>
              </div>
              <div class="modal-body">
                <label>title</label> {{target_widget.title}}<br>
                <p>
                  {{target_widget.payload}}
                </p>
              </div>
              <div class="modal-footer">
                <buttn class="btn btn-default" v-on:click="dialog_close('#show_payload_dialog')">閉じる</buttn>
              </div>
            </div>
          </div>
        </div>

        <div class="modal fade in" id="update_widget_dialog">
          <div class="modal-dialog">
            <div class="modal-content">
              <div class="modal-header">
                <h4 class="modal-title">Widget更新</h4>
              </div>
              <div class="modal-body">
                <label>widget_id</label> {{widget_update.widget_id}}<br>
                <label>title</label> {{widget_update.title}}<br>
                <label>target_url</label> <input type="text" class="form-control" v-model="widget_update.target_url"><br>
                <label>payload</label> <textarea class="form-control" v-model="widget_update.payload"></textarea><br>
              </div>
              <div class="modal-footer">
                <buttn class="btn btn-default" v-on:click="update_widget_call">更新</buttn>
                <buttn class="btn btn-default" v-on:click="dialog_close('#update_widget_dialog')">閉じる</buttn>
              </div>
            </div>
          </div>
        </div>

        <!-- for progress-dialog -->
        <progress-dialog v-bind:title="progress_title"></progress-dialog>
    </div>

    <script src="js/store.js"></script>
    <script src="js/start.js"></script>
</body>
```
</div></details>

<details><summary>ソースコード(start.js)</summary><div>

```js:nodejs\public\widget_console\js\start.js
'use strict';

//const vConsole = new VConsole();
//const remoteConsole = new RemoteConsole("http://[remote server]/logio-post");
//window.datgui = new dat.GUI();

const base_url = "";

var vue_options = {
    el: "#top",
    mixins: [mixins_bootstrap],
    store: vue_store,
    data: {
        target_item: {},
        uuid_list: [],
        widget_list: [],
        target_widget: {},
        widget_update: {},
    },
    computed: {
    },
    methods: {
        delete_uuid_call: async function(){
            if( !this.target_item.uuid )
                return;
            if( !confirm("本当に削除しますか？") )
                return;
            
            var result = await do_post(base_url + "/widget-delete-uuid", { uuid: this.target_item.uuid });
            console.log(result);
            this.uuid_list_update();
            alert('削除しました。');
        },
        uuid_list_update: async function(){
            var result = await do_post(base_url + "/widget-list-uuid");
            console.log(result);
            this.uuid_list = result.rows;
        },
        widget_list_update: async function(){
            var result = await do_post(base_url + "/widget-list", { uuid: this.target_item.uuid });
            console.log(result);
            this.widget_list = result.rows;
        },
        show_payload: function(index){
            this.target_widget = this.widget_list[index];
            this.dialog_open('#show_payload_dialog');
        },
        delete_widget_call: async function(index){
            if( !confirm("本当に削除しますか？") )
                return;
            
            var result = await do_post(base_url + "/widget-delete", { uuid: this.target_item.uuid, widget_id: this.widget_list[index].widget_id });
            console.log(result);
            this.widget_list_update();
            alert('削除しました。');
        },
        update_widget_start: function(index){
            this.widget_update = JSON.parse(JSON.stringify(this.widget_list[index]));
            this.dialog_open('#update_widget_dialog');
        },
        update_widget_call: async function(){
            try{
                var json = JSON.parse(this.widget_update.payload);
                console.log(json);
            }catch(error){
                alert(error);
                return;
            }
            var params = {
                uuid: this.widget_update.uuid,
                widget_id: this.widget_update.widget_id,
                target_url: this.widget_update.target_url,
                payload: this.widget_update.payload
            };
            var result = await do_post(base_url + "/widget-update", params);
            console.log(result);
            this.dialog_close('#update_widget_dialog');
            this.widget_list_update();
            alert('更新しました。');
        }
    },
    created: function(){
    },
    mounted: function(){
        proc_load();

        this.uuid_list_update();
    }
};
vue_add_data(vue_options, { progress_title: '' }); // for progress-dialog
vue_add_global_components(components_bootstrap);
vue_add_global_components(components_utils);

/* add additional components */
  
window.vue = new Vue( vue_options );
```
</div></details>

# 終わりに

レシーバーを再インストールすると、ホーム画面に配置したウィジェットがデフォルト値に戻ってしまう。
対応方法がどこかにあるはず。。。

以上