diff --git a/README.md b/README.md index 81f149e..d593bf9 100644 --- a/README.md +++ b/README.md @@ -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 [![Codemagic build status](https://api.codemagic.io/apps/5da8bdab9f20ef798f7c2c65/5db1862025dc3f0b0288a57a/status_badge.svg)](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/) \ No newline at end of file diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java index e3e25f8..0bc01b6 100644 --- a/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java @@ -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; @@ -18,28 +21,41 @@ import com.google.firebase.iid.InstanceIdResult; 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() { + @Override + public void onComplete(@NonNull Task 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() { - @Override - public void onComplete(@NonNull Task 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(); - } - } - }); } } diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/MessagingService.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/MessagingService.java index 91b53ec..0b7f1aa 100644 --- a/android/app/src/main/java/com/keyboardcrumbs/hassclient/MessagingService.java +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/MessagingService.java @@ -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 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 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); } diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/NotificationActionReceiver.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/NotificationActionReceiver.java index 451137c..bc04126 100644 --- a/android/app/src/main/java/com/keyboardcrumbs/hassclient/NotificationActionReceiver.java +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/NotificationActionReceiver.java @@ -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"); } } } \ No newline at end of file diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/SendTask.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/SendTask.java index c04192a..76571cd 100644 --- a/android/app/src/main/java/com/keyboardcrumbs/hassclient/SendTask.java +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/SendTask.java @@ -26,7 +26,6 @@ public class SendTask extends AsyncTask { 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 { int responseCode = urlConnection.getResponseCode(); - Log.d(TAG, "responseCode: " + responseCode); urlConnection.disconnect(); } catch (Exception e) { Log.e(TAG, "Error sending data", e); diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/UpdateTokenTask.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/UpdateTokenTask.java new file mode 100644 index 0000000..91455c0 --- /dev/null +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/UpdateTokenTask.java @@ -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 { + + private static final String TAG = "UpdateTokenTask"; + + private WeakReference 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; + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 47ea6f3..5d6deb9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 _reportError(dynamic error, dynamic stackTrace) async { // Print the exception to the console. diff --git a/lib/managers/app_settings.dart b/lib/managers/app_settings.dart index 9e7d0e3..3b7d030 100644 --- a/lib/managers/app_settings.dart +++ b/lib/managers/app_settings.dart @@ -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 loadSingle(String key) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + return prefs.get('$key'); + } + Future save(Map settings) async { if (settings != null && settings.isNotEmpty) { SharedPreferences prefs = await SharedPreferences.getInstance(); diff --git a/lib/managers/mobile_app_integration_manager.class.dart b/lib/managers/mobile_app_integration_manager.class.dart index 45dd716..7b2575f 100644 --- a/lib/managers/mobile_app_integration_manager.class.dart +++ b/lib/managers/mobile_app_integration_manager.class.dart @@ -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( diff --git a/pubspec.yaml b/pubspec.yaml index 249db6b..9f37468 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: hass_client description: Home Assistant Android Client -version: 0.0.0+1146 +version: 0.0.0+1151 environment: