commit
1080076e3b
@ -1,6 +1,6 @@
|
||||
# HA Client
|
||||
## Native Android client for Home Assistant
|
||||
### With notifications and Lovelace UI support
|
||||
### With actionable notifications, location tracking and Lovelace UI support
|
||||
|
||||
Visit [ha-client.app](http://ha-client.app/) for more info.
|
||||
|
||||
@ -12,3 +12,7 @@ Discuss it on [Discord](https://discord.gg/u9vq7QE) or at [Home Assistant commun
|
||||
|
||||
#### Last release build status
|
||||
[](https://codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5db1862025dc3f0b0288a57a/latest_build)
|
||||
|
||||
#### Special thanks to
|
||||
- [Crewski](https://github.com/Crewski) for his [HANotify](https://github.com/Crewski/HANotify)
|
||||
- [Home Assistant](https://github.com/home-assistant) for some support and [Home Assistant](https://www.home-assistant.io/)
|
@ -11,6 +11,9 @@ import android.content.SharedPreferences;
|
||||
import android.content.SharedPreferences.Editor;
|
||||
import android.os.Bundle;
|
||||
|
||||
import io.flutter.plugin.common.MethodCall;
|
||||
import io.flutter.plugin.common.MethodChannel;
|
||||
|
||||
import com.google.android.gms.tasks.OnCompleteListener;
|
||||
import com.google.android.gms.tasks.Task;
|
||||
import com.google.firebase.iid.FirebaseInstanceId;
|
||||
@ -19,27 +22,40 @@ import com.google.firebase.messaging.FirebaseMessaging;
|
||||
|
||||
public class MainActivity extends FlutterActivity {
|
||||
|
||||
private static final String CHANNEL = "com.keyboardcrumbs.hassclient/native";
|
||||
|
||||
@Override
|
||||
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
|
||||
GeneratedPluginRegistrant.registerWith(flutterEngine);
|
||||
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL).setMethodCallHandler(
|
||||
new MethodChannel.MethodCallHandler() {
|
||||
@Override
|
||||
public void onMethodCall(MethodCall call, MethodChannel.Result result) {
|
||||
if (call.method.equals("getFCMToken")) {
|
||||
FirebaseInstanceId.getInstance().getInstanceId()
|
||||
.addOnCompleteListener(new OnCompleteListener<InstanceIdResult>() {
|
||||
@Override
|
||||
public void onComplete(@NonNull Task<InstanceIdResult> task) {
|
||||
if (task.isSuccessful()) {
|
||||
Context context = getActivity();
|
||||
String token = task.getResult().getToken();
|
||||
UpdateTokenTask updateTokenTask = new UpdateTokenTask(context);
|
||||
updateTokenTask.execute(token);
|
||||
result.success(token);
|
||||
} else {
|
||||
result.error("fcm_error", task.getException().getMessage(), task.getException());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
FirebaseInstanceId.getInstance().getInstanceId()
|
||||
.addOnCompleteListener(new OnCompleteListener<InstanceIdResult>() {
|
||||
@Override
|
||||
public void onComplete(@NonNull Task<InstanceIdResult> task) {
|
||||
if (task.isSuccessful()) {
|
||||
Context context = getActivity();
|
||||
SharedPreferences.Editor editor = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE).edit();
|
||||
String token = task.getResult().getToken();
|
||||
editor.putString("flutter.fcm-token", token);
|
||||
editor.commit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import android.media.RingtoneManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
@ -31,10 +30,8 @@ public class MessagingService extends FirebaseMessagingService {
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||
Log.d(TAG, "From: " + remoteMessage.getFrom());
|
||||
Map<String, String> data = remoteMessage.getData();
|
||||
if (data.size() > 0) {
|
||||
Log.d(TAG, "Message data payload: " + data);
|
||||
if (data.containsKey("body") || data.containsKey("title")) {
|
||||
sendNotification(data);
|
||||
}
|
||||
@ -43,17 +40,19 @@ public class MessagingService extends FirebaseMessagingService {
|
||||
|
||||
@Override
|
||||
public void onNewToken(String token) {
|
||||
Log.d(TAG, "Refreshed token: " + token);
|
||||
//TODO update token
|
||||
UpdateTokenTask updateTokenTask = new UpdateTokenTask(this);
|
||||
updateTokenTask.execute(token);
|
||||
}
|
||||
|
||||
private void sendNotification(Map<String, String> data) {
|
||||
String channelId, messageBody, messageTitle, imageUrl;
|
||||
String nTag;
|
||||
String channelId, messageBody, messageTitle, imageUrl, nTag, channelDescription;
|
||||
boolean autoCancel;
|
||||
if (!data.containsKey("channelId")) {
|
||||
channelId = "ha_notify";
|
||||
channelDescription = "Default notification channel";
|
||||
} else {
|
||||
channelId = data.get("channelId");
|
||||
channelDescription = channelId;
|
||||
}
|
||||
if (!data.containsKey("body")) {
|
||||
messageBody = "";
|
||||
@ -70,7 +69,28 @@ public class MessagingService extends FirebaseMessagingService {
|
||||
} else {
|
||||
nTag = data.get("tag");
|
||||
}
|
||||
Log.d(TAG, "Notification tag: " + nTag);
|
||||
if (data.containsKey("dismiss")) {
|
||||
try {
|
||||
boolean dismiss = Boolean.parseBoolean(data.get("dismiss"));
|
||||
if (dismiss) {
|
||||
NotificationManager notificationManager =
|
||||
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancel(nTag, 0);
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
//nope
|
||||
}
|
||||
}
|
||||
if (data.containsKey("autoDismiss")) {
|
||||
try {
|
||||
autoCancel = Boolean.parseBoolean(data.get("autoDismiss"));
|
||||
} catch (Exception e) {
|
||||
autoCancel = true;
|
||||
}
|
||||
} else {
|
||||
autoCancel = true;
|
||||
}
|
||||
imageUrl = data.get("image");
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
@ -82,7 +102,7 @@ public class MessagingService extends FirebaseMessagingService {
|
||||
.setSmallIcon(R.drawable.mini_icon)
|
||||
.setContentTitle(messageTitle)
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setAutoCancel(autoCancel)
|
||||
.setSound(defaultSoundUri)
|
||||
.setContentIntent(pendingIntent);
|
||||
if (URLUtil.isValidUrl(imageUrl)) {
|
||||
@ -95,10 +115,11 @@ public class MessagingService extends FirebaseMessagingService {
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
if (data.containsKey("action" + i)) {
|
||||
Intent broadcastIntent = new Intent(this, NotificationActionReceiver.class);
|
||||
Log.d(TAG, "Putting a tag to the action: " + nTag);
|
||||
broadcastIntent.putExtra("tag", nTag);
|
||||
if (autoCancel) {
|
||||
broadcastIntent.putExtra("tag", nTag);
|
||||
}
|
||||
broadcastIntent.putExtra("actionData", data.get("action" + i + "_data"));
|
||||
PendingIntent actionIntent = PendingIntent.getBroadcast(this, i, broadcastIntent, 0);
|
||||
PendingIntent actionIntent = PendingIntent.getBroadcast(this, i, broadcastIntent, PendingIntent.FLAG_CANCEL_CURRENT);
|
||||
notificationBuilder.addAction(R.drawable.mini_icon, data.get("action" + i), actionIntent);
|
||||
}
|
||||
}
|
||||
@ -108,8 +129,8 @@ public class MessagingService extends FirebaseMessagingService {
|
||||
// Since android Oreo notification channel is needed.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(channelId,
|
||||
"Home Assistant notifications",
|
||||
NotificationManager.IMPORTANCE_DEFAULT);
|
||||
channelDescription,
|
||||
NotificationManager.IMPORTANCE_HIGH);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
|
@ -20,23 +20,21 @@ public class NotificationActionReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String rawActionData = intent.getStringExtra("actionData");
|
||||
String notificationTag = intent.getStringExtra("tag");
|
||||
Log.d(TAG, "Has 'tag': " + intent.hasExtra("tag"));
|
||||
Log.d(TAG, "Canceling notification by tag: " + notificationTag);
|
||||
NotificationManager notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancel(notificationTag, 0);
|
||||
if (intent.hasExtra("tag")) {
|
||||
String notificationTag = intent.getStringExtra("tag");
|
||||
NotificationManager notificationManager = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
notificationManager.cancel(notificationTag, 0);
|
||||
}
|
||||
SharedPreferences prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE);
|
||||
String webhookId = prefs.getString("flutter.app-webhook-id", null);
|
||||
if (webhookId != null) {
|
||||
try {
|
||||
Log.d(TAG, "Got webhook id");
|
||||
String requestUrl = prefs.getString("flutter.hassio-res-protocol", "") +
|
||||
"://" +
|
||||
prefs.getString("flutter.hassio-domain", "") +
|
||||
":" +
|
||||
prefs.getString("flutter.hassio-port", "") + "/api/webhook/" + webhookId;
|
||||
JSONObject actionData = new JSONObject(rawActionData);
|
||||
Log.d(TAG, "request url: " + requestUrl);
|
||||
if (URLUtil.isValidUrl(requestUrl)) {
|
||||
JSONObject dataToSend = new JSONObject();
|
||||
JSONObject requestData = new JSONObject();
|
||||
@ -50,20 +48,22 @@ public class NotificationActionReceiver extends BroadcastReceiver {
|
||||
} else {
|
||||
dataToSend.put("type", "fire_event");
|
||||
requestData.put("event_type", "ha_client_event");
|
||||
JSONObject eventData = new JSONObject();
|
||||
eventData.put("action", actionData.getString("action"));
|
||||
requestData.put("event_data", eventData);
|
||||
}
|
||||
dataToSend.put("data", requestData);
|
||||
String stringRequest = dataToSend.toString();
|
||||
Log.d(TAG, "Data to send home: " + stringRequest);
|
||||
SendTask sendTask = new SendTask();
|
||||
sendTask.execute(requestUrl, stringRequest);
|
||||
} else {
|
||||
Log.w(TAG, "Invalid url");
|
||||
Log.w(TAG, "Invalid HA url");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error handling notification action", e);
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Webhook id not found");
|
||||
Log.w(TAG, "Webhook id not found");
|
||||
}
|
||||
}
|
||||
}
|
@ -26,7 +26,6 @@ public class SendTask extends AsyncTask<String, String, String> {
|
||||
String data = params[1];
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Connecting and sending...");
|
||||
URL url = new URL(urlString);
|
||||
HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection();
|
||||
urlConnection.setRequestMethod("POST");
|
||||
@ -38,7 +37,6 @@ public class SendTask extends AsyncTask<String, String, String> {
|
||||
|
||||
int responseCode = urlConnection.getResponseCode();
|
||||
|
||||
Log.d(TAG, "responseCode: " + responseCode);
|
||||
urlConnection.disconnect();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error sending data", e);
|
||||
|
@ -0,0 +1,46 @@
|
||||
package com.keyboardcrumbs.hassclient;
|
||||
|
||||
import android.util.Log;
|
||||
import android.os.AsyncTask;
|
||||
|
||||
import java.net.URL;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.io.OutputStream;
|
||||
|
||||
import android.webkit.URLUtil;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.Context;
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
|
||||
public class UpdateTokenTask extends AsyncTask<String, String, String> {
|
||||
|
||||
private static final String TAG = "UpdateTokenTask";
|
||||
|
||||
private WeakReference<Context> contextRef;
|
||||
|
||||
public UpdateTokenTask(Context context){
|
||||
contextRef = new WeakReference<>(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPreExecute() {
|
||||
super.onPreExecute();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String doInBackground(String... params) {
|
||||
Log.d(TAG, "Updating push token");
|
||||
Context context = contextRef.get();
|
||||
if (context != null) {
|
||||
String token = params[0];
|
||||
SharedPreferences prefs = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE);
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
editor.putString("flutter.npush-token", token);
|
||||
editor.commit();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
@ -16,10 +16,8 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:charts_flutter/flutter.dart' as charts;
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
|
||||
//import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:device_info/device_info.dart';
|
||||
//import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'plugins/dynamic_multi_column_layout.dart';
|
||||
import 'plugins/spoiler_card.dart';
|
||||
@ -161,7 +159,7 @@ EventBus eventBus = new EventBus();
|
||||
//FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = new FlutterLocalNotificationsPlugin();
|
||||
const String appName = 'HA Client';
|
||||
const String appVersion = String.fromEnvironment('versionName', defaultValue: '0.0.0');
|
||||
const whatsNewUrl = 'http://ha-client.app/service/whats_new_1.1.0.md';
|
||||
const whatsNewUrl = 'http://ha-client.app/service/whats_new_1.1.0-b2.md';
|
||||
|
||||
Future<void> _reportError(dynamic error, dynamic stackTrace) async {
|
||||
// Print the exception to the console.
|
||||
|
@ -20,10 +20,8 @@ class AppSettings {
|
||||
String tempToken;
|
||||
String oauthUrl;
|
||||
String webhookId;
|
||||
String fcmToken;
|
||||
double haVersion;
|
||||
bool scrollBadges;
|
||||
int appIntegrationVersion;
|
||||
AppTheme appTheme;
|
||||
final int defaultLocationUpdateIntervalMinutes = 20;
|
||||
Duration locationUpdateInterval;
|
||||
@ -41,12 +39,10 @@ class AppSettings {
|
||||
if (full) {
|
||||
Logger.d('Loading settings...');
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
fcmToken = prefs.getString('fcm-token');
|
||||
_domain = prefs.getString('hassio-domain');
|
||||
_port = prefs.getString('hassio-port');
|
||||
webhookId = prefs.getString('app-webhook-id');
|
||||
mobileAppDeviceName = prefs.getString('app-integration-device-name');
|
||||
appIntegrationVersion = prefs.getInt('app-integration-version') ?? 0;
|
||||
scrollBadges = prefs.getBool('scroll-badges') ?? true;
|
||||
displayHostname = "$_domain:$_port";
|
||||
webSocketAPIEndpoint =
|
||||
@ -71,6 +67,11 @@ class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
Future<dynamic> loadSingle(String key) async {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
return prefs.get('$key');
|
||||
}
|
||||
|
||||
Future save(Map<String, dynamic> settings) async {
|
||||
if (settings != null && settings.isNotEmpty) {
|
||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||
|
@ -2,8 +2,6 @@ part of '../main.dart';
|
||||
|
||||
class MobileAppIntegrationManager {
|
||||
|
||||
static const INTEGRATION_VERSION = 3;
|
||||
|
||||
static final _appRegistrationData = {
|
||||
"device_name": "",
|
||||
"app_version": "$appVersion",
|
||||
@ -23,10 +21,28 @@ class MobileAppIntegrationManager {
|
||||
return '${HomeAssistant().userName}\'s ${DeviceInfoManager().model}';
|
||||
}
|
||||
|
||||
static Future checkAppRegistration() {
|
||||
static const platform = const MethodChannel('com.keyboardcrumbs.hassclient/native');
|
||||
|
||||
static Future checkAppRegistration() async {
|
||||
String fcmToken = await AppSettings().loadSingle('npush-token');
|
||||
if (fcmToken != null) {
|
||||
Logger.d("[MobileAppIntegrationManager] token exist");
|
||||
await _doCheck(fcmToken);
|
||||
} else {
|
||||
Logger.d("[MobileAppIntegrationManager] no fcm token. Requesting...");
|
||||
try {
|
||||
fcmToken = await platform.invokeMethod('getFCMToken');
|
||||
await _doCheck(fcmToken);
|
||||
} on PlatformException catch (e) {
|
||||
Logger.e('[MobileAppIntegrationManager] Failed to get FCM token from native: ${e.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static Future _doCheck(String fcmToken) {
|
||||
Completer completer = Completer();
|
||||
_appRegistrationData["device_name"] = AppSettings().mobileAppDeviceName ?? getDefaultDeviceName();
|
||||
(_appRegistrationData["app_data"] as Map)["push_token"] = "${AppSettings().fcmToken}";
|
||||
(_appRegistrationData["app_data"] as Map)["push_token"] = "$fcmToken";
|
||||
if (AppSettings().webhookId == null) {
|
||||
Logger.d("Mobile app was not registered yet. Registering...");
|
||||
var registrationData = Map.from(_appRegistrationData);
|
||||
@ -49,10 +65,8 @@ class MobileAppIntegrationManager {
|
||||
Logger.d("Processing registration responce...");
|
||||
var responseObject = json.decode(response);
|
||||
AppSettings().webhookId = responseObject["webhook_id"];
|
||||
AppSettings().appIntegrationVersion = INTEGRATION_VERSION;
|
||||
AppSettings().save({
|
||||
'app-webhook-id': responseObject["webhook_id"],
|
||||
'app-integration-version': INTEGRATION_VERSION
|
||||
'app-webhook-id': responseObject["webhook_id"]
|
||||
}).then((prefs) {
|
||||
completer.complete();
|
||||
eventBus.fire(ShowPopupEvent(
|
||||
@ -76,7 +90,6 @@ class MobileAppIntegrationManager {
|
||||
}
|
||||
_showError();
|
||||
});
|
||||
return completer.future;
|
||||
} else {
|
||||
Logger.d("App was previously registered. Checking...");
|
||||
var updateData = {
|
||||
@ -98,12 +111,7 @@ class MobileAppIntegrationManager {
|
||||
Logger.w("No registration data in response. MobileApp integration was removed or broken");
|
||||
_askToRegisterApp();
|
||||
} else {
|
||||
if (INTEGRATION_VERSION > AppSettings().appIntegrationVersion) {
|
||||
Logger.d('App registration needs to be updated');
|
||||
_askToRemoveAndRegisterApp();
|
||||
} else {
|
||||
Logger.d('App registration works fine');
|
||||
}
|
||||
Logger.d('App registration works fine');
|
||||
}
|
||||
completer.complete();
|
||||
}).catchError((e) {
|
||||
@ -119,8 +127,8 @@ class MobileAppIntegrationManager {
|
||||
}
|
||||
completer.complete();
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
static void _showError() {
|
||||
@ -137,20 +145,6 @@ class MobileAppIntegrationManager {
|
||||
));
|
||||
}
|
||||
|
||||
static void _askToRemoveAndRegisterApp() {
|
||||
eventBus.fire(ShowPopupEvent(
|
||||
popup: Popup(
|
||||
title: "Mobile app integration needs to be updated",
|
||||
body: "You need to update HA Client integration to continue using notifications and location tracking. Please remove 'Mobile App' integration for this device from your Home Assistant and restart Home Assistant. Then go back to HA Client to create app integration again.",
|
||||
positiveText: "Ok",
|
||||
negativeText: "Report an issue",
|
||||
onNegative: () {
|
||||
Launcher.launchURLInBrowser("https://github.com/estevez-dev/ha_client/issues/new");
|
||||
},
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
static void _askToRegisterApp() {
|
||||
eventBus.fire(ShowPopupEvent(
|
||||
popup: RegisterAppPopup(
|
||||
|
@ -1,7 +1,7 @@
|
||||
name: hass_client
|
||||
description: Home Assistant Android Client
|
||||
|
||||
version: 0.0.0+1146
|
||||
version: 0.0.0+1151
|
||||
|
||||
|
||||
environment:
|
||||
|
Reference in New Issue
Block a user