commit
f7e139b963
@ -60,6 +60,10 @@
|
|||||||
android:name=".LocationUpdatesService"
|
android:name=".LocationUpdatesService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
<service
|
||||||
|
android:name=".LocationRequestService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
<receiver android:name=".NotificationActionReceiver" android:exported="true">
|
<receiver android:name=".NotificationActionReceiver" android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
@ -72,9 +76,10 @@
|
|||||||
<action android:name="android.app.action.NEXT_ALARM_CLOCK_CHANGED" />
|
<action android:name="android.app.action.NEXT_ALARM_CLOCK_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<receiver android:name=".LocationUpdatesAfterReboot">
|
<receiver android:name=".RestartLocationUpdate">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<service
|
<service
|
||||||
|
@ -0,0 +1,146 @@
|
|||||||
|
package com.keyboardcrumbs.hassclient;
|
||||||
|
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.Service;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.location.Location;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.HandlerThread;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.work.Constraints;
|
||||||
|
import androidx.work.Data;
|
||||||
|
import androidx.work.ExistingWorkPolicy;
|
||||||
|
import androidx.work.NetworkType;
|
||||||
|
import androidx.work.OneTimeWorkRequest;
|
||||||
|
import androidx.work.WorkManager;
|
||||||
|
|
||||||
|
import com.google.android.gms.location.FusedLocationProviderClient;
|
||||||
|
import com.google.android.gms.location.LocationCallback;
|
||||||
|
import com.google.android.gms.location.LocationRequest;
|
||||||
|
import com.google.android.gms.location.LocationResult;
|
||||||
|
import com.google.android.gms.location.LocationServices;
|
||||||
|
|
||||||
|
public class LocationRequestService extends Service {
|
||||||
|
|
||||||
|
private static final String TAG = LocationRequestService.class.getSimpleName();
|
||||||
|
|
||||||
|
private NotificationManager mNotificationManager;
|
||||||
|
|
||||||
|
private LocationRequest mLocationRequest;
|
||||||
|
|
||||||
|
private FusedLocationProviderClient mFusedLocationClient;
|
||||||
|
|
||||||
|
private LocationCallback mLocationCallback;
|
||||||
|
|
||||||
|
private Handler mServiceHandler;
|
||||||
|
|
||||||
|
public LocationRequestService() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
mFusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
|
||||||
|
|
||||||
|
mLocationCallback = new LocationCallback() {
|
||||||
|
@Override
|
||||||
|
public void onLocationResult(LocationResult locationResult) {
|
||||||
|
super.onLocationResult(locationResult);
|
||||||
|
onNewLocation(locationResult.getLastLocation());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mLocationRequest = new LocationRequest();
|
||||||
|
|
||||||
|
HandlerThread handlerThread = new HandlerThread(TAG);
|
||||||
|
handlerThread.start();
|
||||||
|
mServiceHandler = new Handler(handlerThread.getLooper());
|
||||||
|
mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
CharSequence name = "Location requests";
|
||||||
|
NotificationChannel mChannel =
|
||||||
|
new NotificationChannel(LocationUtils.ONETIME_NOTIFICATION_CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW);
|
||||||
|
|
||||||
|
mNotificationManager.createNotificationChannel(mChannel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
|
Log.i(TAG, "Service started. startId="+startId);
|
||||||
|
|
||||||
|
requestLocationUpdates();
|
||||||
|
|
||||||
|
return START_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
try {
|
||||||
|
mFusedLocationClient.removeLocationUpdates(mLocationCallback);
|
||||||
|
} catch (SecurityException unlikely) {
|
||||||
|
//When we lost permission
|
||||||
|
Log.i(TAG, "No location permission");
|
||||||
|
}
|
||||||
|
mServiceHandler.removeCallbacksAndMessages(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(Intent intent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void requestLocationUpdates() {
|
||||||
|
Log.i(TAG, "Requesting location update in 5 seconds.");
|
||||||
|
mLocationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
|
||||||
|
mLocationRequest.setInterval(5000);
|
||||||
|
|
||||||
|
startForeground(LocationUtils.ONETIME_NOTIFICATION_ID, LocationUtils.getRequestNotification(this, null, LocationUtils.ONETIME_NOTIFICATION_CHANNEL_ID));
|
||||||
|
try {
|
||||||
|
mFusedLocationClient.requestLocationUpdates(mLocationRequest,
|
||||||
|
mLocationCallback, Looper.myLooper());
|
||||||
|
} catch (SecurityException unlikely) {
|
||||||
|
stopSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onNewLocation(Location location) {
|
||||||
|
Log.i(TAG, "New location: " + location);
|
||||||
|
|
||||||
|
mNotificationManager.notify(LocationUtils.ONETIME_NOTIFICATION_ID, LocationUtils.getRequestNotification(
|
||||||
|
this,
|
||||||
|
location,
|
||||||
|
LocationUtils.ONETIME_NOTIFICATION_CHANNEL_ID
|
||||||
|
));
|
||||||
|
|
||||||
|
Constraints constraints = new Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Data locationData = new Data.Builder()
|
||||||
|
.putInt(SendDataHomeWorker.DATA_TYPE_KEY, SendDataHomeWorker.DATA_TYPE_LOCATION)
|
||||||
|
.putDouble("Lat", location.getLatitude())
|
||||||
|
.putDouble("Long", location.getLongitude())
|
||||||
|
.putFloat("Acc", location.getAccuracy())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
|
||||||
|
OneTimeWorkRequest uploadWorkRequest =
|
||||||
|
new OneTimeWorkRequest.Builder(SendDataHomeWorker.class)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setInputData(locationData)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
WorkManager
|
||||||
|
.getInstance(getApplicationContext())
|
||||||
|
.enqueueUniqueWork("SendLocationUpdate", ExistingWorkPolicy.REPLACE, uploadWorkRequest);
|
||||||
|
stopSelf();
|
||||||
|
}
|
||||||
|
}
|
@ -12,7 +12,6 @@ import android.os.IBinder;
|
|||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.work.BackoffPolicy;
|
|
||||||
import androidx.work.Constraints;
|
import androidx.work.Constraints;
|
||||||
import androidx.work.Data;
|
import androidx.work.Data;
|
||||||
import androidx.work.ExistingWorkPolicy;
|
import androidx.work.ExistingWorkPolicy;
|
||||||
@ -28,8 +27,6 @@ import com.google.android.gms.location.LocationRequest;
|
|||||||
import com.google.android.gms.location.LocationResult;
|
import com.google.android.gms.location.LocationResult;
|
||||||
import com.google.android.gms.location.LocationServices;
|
import com.google.android.gms.location.LocationServices;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
public class LocationUpdatesService extends Service {
|
public class LocationUpdatesService extends Service {
|
||||||
|
|
||||||
private static final String TAG = LocationUpdatesService.class.getSimpleName();
|
private static final String TAG = LocationUpdatesService.class.getSimpleName();
|
||||||
@ -77,7 +74,7 @@ public class LocationUpdatesService extends Service {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
Log.i(TAG, "Service started");
|
Log.i(TAG, "Service started. startId="+startId);
|
||||||
|
|
||||||
requestLocationUpdates();
|
requestLocationUpdates();
|
||||||
|
|
||||||
@ -103,11 +100,17 @@ public class LocationUpdatesService extends Service {
|
|||||||
|
|
||||||
private void requestLocationUpdates() {
|
private void requestLocationUpdates() {
|
||||||
long requestInterval = LocationUtils.getLocationUpdateIntervals(getApplicationContext());
|
long requestInterval = LocationUtils.getLocationUpdateIntervals(getApplicationContext());
|
||||||
int priority = LocationUtils.getLocationUpdatesPriority(getApplicationContext());
|
int priority;
|
||||||
|
if (requestInterval >= 600000) {
|
||||||
|
mLocationRequest.setFastestInterval(60000);
|
||||||
|
priority = LocationRequest.PRIORITY_BALANCED_POWER_ACCURACY;
|
||||||
|
} else {
|
||||||
|
priority = LocationRequest.PRIORITY_HIGH_ACCURACY;
|
||||||
|
}
|
||||||
Log.i(TAG, "Requesting location updates. Every " + requestInterval + "ms with priority of " + priority);
|
Log.i(TAG, "Requesting location updates. Every " + requestInterval + "ms with priority of " + priority);
|
||||||
mLocationRequest.setPriority(priority);
|
mLocationRequest.setPriority(priority);
|
||||||
mLocationRequest.setInterval(requestInterval);
|
mLocationRequest.setInterval(requestInterval);
|
||||||
mLocationRequest.setFastestInterval(requestInterval);
|
|
||||||
startForeground(LocationUtils.SERVICE_NOTIFICATION_ID, LocationUtils.getNotification(this, null, LocationUtils.SERVICE_NOTIFICATION_CHANNEL_ID));
|
startForeground(LocationUtils.SERVICE_NOTIFICATION_ID, LocationUtils.getNotification(this, null, LocationUtils.SERVICE_NOTIFICATION_CHANNEL_ID));
|
||||||
try {
|
try {
|
||||||
mFusedLocationClient.requestLocationUpdates(mLocationRequest,
|
mFusedLocationClient.requestLocationUpdates(mLocationRequest,
|
||||||
@ -120,7 +123,11 @@ public class LocationUpdatesService extends Service {
|
|||||||
private void onNewLocation(Location location) {
|
private void onNewLocation(Location location) {
|
||||||
Log.i(TAG, "New location: " + location);
|
Log.i(TAG, "New location: " + location);
|
||||||
|
|
||||||
mNotificationManager.notify(LocationUtils.SERVICE_NOTIFICATION_ID, LocationUtils.getNotification(this, location, LocationUtils.SERVICE_NOTIFICATION_CHANNEL_ID));
|
mNotificationManager.notify(LocationUtils.SERVICE_NOTIFICATION_ID, LocationUtils.getNotification(
|
||||||
|
this,
|
||||||
|
location,
|
||||||
|
LocationUtils.SERVICE_NOTIFICATION_CHANNEL_ID
|
||||||
|
));
|
||||||
|
|
||||||
Constraints constraints = new Constraints.Builder()
|
Constraints constraints = new Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
@ -136,10 +143,6 @@ public class LocationUpdatesService extends Service {
|
|||||||
|
|
||||||
OneTimeWorkRequest uploadWorkRequest =
|
OneTimeWorkRequest uploadWorkRequest =
|
||||||
new OneTimeWorkRequest.Builder(SendDataHomeWorker.class)
|
new OneTimeWorkRequest.Builder(SendDataHomeWorker.class)
|
||||||
.setBackoffCriteria(
|
|
||||||
BackoffPolicy.EXPONENTIAL,
|
|
||||||
10,
|
|
||||||
TimeUnit.SECONDS)
|
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.setInputData(locationData)
|
.setInputData(locationData)
|
||||||
.build();
|
.build();
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
package com.keyboardcrumbs.hassclient;
|
package com.keyboardcrumbs.hassclient;
|
||||||
|
|
||||||
import android.app.AlarmManager;
|
|
||||||
import android.app.NotificationChannel;
|
import android.app.NotificationChannel;
|
||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@ -10,7 +9,6 @@ import android.os.Looper;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.concurrent.futures.CallbackToFutureAdapter;
|
import androidx.concurrent.futures.CallbackToFutureAdapter;
|
||||||
import androidx.work.BackoffPolicy;
|
|
||||||
import androidx.work.Constraints;
|
import androidx.work.Constraints;
|
||||||
import androidx.work.Data;
|
import androidx.work.Data;
|
||||||
import androidx.work.ExistingWorkPolicy;
|
import androidx.work.ExistingWorkPolicy;
|
||||||
@ -29,8 +27,6 @@ import com.google.common.util.concurrent.ListenableFuture;
|
|||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import static android.content.Context.NOTIFICATION_SERVICE;
|
|
||||||
|
|
||||||
public class LocationUpdatesWorker extends ListenableWorker {
|
public class LocationUpdatesWorker extends ListenableWorker {
|
||||||
|
|
||||||
private Context currentContext;
|
private Context currentContext;
|
||||||
@ -73,10 +69,6 @@ public class LocationUpdatesWorker extends ListenableWorker {
|
|||||||
|
|
||||||
OneTimeWorkRequest uploadWorkRequest =
|
OneTimeWorkRequest uploadWorkRequest =
|
||||||
new OneTimeWorkRequest.Builder(SendDataHomeWorker.class)
|
new OneTimeWorkRequest.Builder(SendDataHomeWorker.class)
|
||||||
.setBackoffCriteria(
|
|
||||||
BackoffPolicy.EXPONENTIAL,
|
|
||||||
10,
|
|
||||||
TimeUnit.SECONDS)
|
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.setInputData(locationData)
|
.setInputData(locationData)
|
||||||
.build();
|
.build();
|
||||||
@ -106,10 +98,8 @@ public class LocationUpdatesWorker extends ListenableWorker {
|
|||||||
};
|
};
|
||||||
|
|
||||||
LocationRequest locationRequest = new LocationRequest();
|
LocationRequest locationRequest = new LocationRequest();
|
||||||
int accuracy = LocationUtils.getLocationUpdatesPriority(getApplicationContext());
|
locationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);
|
||||||
locationRequest.setPriority(accuracy);
|
|
||||||
locationRequest.setInterval(5000);
|
locationRequest.setInterval(5000);
|
||||||
locationRequest.setFastestInterval(1000);
|
|
||||||
try {
|
try {
|
||||||
fusedLocationClient.requestLocationUpdates(locationRequest,
|
fusedLocationClient.requestLocationUpdates(locationRequest,
|
||||||
callback, Looper.myLooper());
|
callback, Looper.myLooper());
|
||||||
|
@ -9,6 +9,8 @@ import android.os.Build;
|
|||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy;
|
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||||
|
import androidx.work.ExistingWorkPolicy;
|
||||||
|
import androidx.work.OneTimeWorkRequest;
|
||||||
import androidx.work.PeriodicWorkRequest;
|
import androidx.work.PeriodicWorkRequest;
|
||||||
import androidx.work.WorkManager;
|
import androidx.work.WorkManager;
|
||||||
|
|
||||||
@ -20,21 +22,25 @@ class LocationUtils {
|
|||||||
|
|
||||||
static final String KEY_REQUESTING_LOCATION_UPDATES = "flutter.location-updates-state";
|
static final String KEY_REQUESTING_LOCATION_UPDATES = "flutter.location-updates-state";
|
||||||
static final String KEY_LOCATION_UPDATE_INTERVAL = "flutter.location-updates-interval";
|
static final String KEY_LOCATION_UPDATE_INTERVAL = "flutter.location-updates-interval";
|
||||||
static final String KEY_LOCATION_UPDATE_PRIORITY = "flutter.location-updates-priority";
|
|
||||||
static final String KEY_LOCATION_SHOW_NOTIFICATION = "flutter.location-updates-show-notification";
|
static final String KEY_LOCATION_SHOW_NOTIFICATION = "flutter.location-updates-show-notification";
|
||||||
|
|
||||||
static final String WORKER_NOTIFICATION_CHANNEL_ID = "location_worker";
|
static final String WORKER_NOTIFICATION_CHANNEL_ID = "location_worker";
|
||||||
static final int WORKER_NOTIFICATION_ID = 954322;
|
static final int WORKER_NOTIFICATION_ID = 954322;
|
||||||
static final String SERVICE_NOTIFICATION_CHANNEL_ID = "location_service";
|
static final String SERVICE_NOTIFICATION_CHANNEL_ID = "location_service";
|
||||||
static final int SERVICE_NOTIFICATION_ID = 954311;
|
static final int SERVICE_NOTIFICATION_ID = 954311;
|
||||||
|
static final String ONETIME_NOTIFICATION_CHANNEL_ID = "location_request";
|
||||||
|
static final int ONETIME_NOTIFICATION_ID = 954333;
|
||||||
|
|
||||||
|
static final String REQUEST_LOCATION_NOTIFICATION = "request_location_update";
|
||||||
|
|
||||||
static final String LOCATION_WORK_NAME = "HALocationWorker";
|
static final String LOCATION_WORK_NAME = "HALocationWorker";
|
||||||
|
static final String LOCATION_REQUEST_NAME = "HALocationRequest";
|
||||||
|
|
||||||
static final int LOCATION_UPDATES_DISABLED = 0;
|
static final int LOCATION_UPDATES_DISABLED = 0;
|
||||||
static final int LOCATION_UPDATES_SERVICE = 1;
|
static final int LOCATION_UPDATES_SERVICE = 1;
|
||||||
static final int LOCATION_UPDATES_WORKER = 2;
|
static final int LOCATION_UPDATES_WORKER = 2;
|
||||||
|
|
||||||
static final int DEFAULT_LOCATION_UPDATE_INTERVAL_S = 900; //15 minutes
|
static final int DEFAULT_LOCATION_UPDATE_INTERVAL_MS = 900000; //15 minutes
|
||||||
static final long MIN_WORKER_LOCATION_UPDATE_INTERVAL_MS = 900000; //15 minutes
|
static final long MIN_WORKER_LOCATION_UPDATE_INTERVAL_MS = 900000; //15 minutes
|
||||||
|
|
||||||
static int getLocationUpdatesState(Context context) {
|
static int getLocationUpdatesState(Context context) {
|
||||||
@ -42,11 +48,7 @@ class LocationUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static long getLocationUpdateIntervals(Context context) {
|
static long getLocationUpdateIntervals(Context context) {
|
||||||
return context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE).getLong(KEY_LOCATION_UPDATE_INTERVAL, DEFAULT_LOCATION_UPDATE_INTERVAL_S) * 1000;
|
return context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE).getLong(KEY_LOCATION_UPDATE_INTERVAL, DEFAULT_LOCATION_UPDATE_INTERVAL_MS);
|
||||||
}
|
|
||||||
|
|
||||||
static int getLocationUpdatesPriority(Context context) {
|
|
||||||
return (int) context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE).getLong(KEY_LOCATION_UPDATE_PRIORITY, 102);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean showNotification(Context context) {
|
static boolean showNotification(Context context) {
|
||||||
@ -60,13 +62,12 @@ class LocationUtils {
|
|||||||
.apply();
|
.apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String getLocationText(Location location) {
|
static void setLocationUpdatesSettings(Context context, long interval, boolean showNotification) {
|
||||||
return location == null ? "Accuracy: unknown" :
|
context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
|
||||||
"Accuracy: " + location.getAccuracy();
|
.edit()
|
||||||
}
|
.putBoolean(KEY_LOCATION_SHOW_NOTIFICATION, showNotification)
|
||||||
|
.putLong(KEY_LOCATION_UPDATE_INTERVAL, interval)
|
||||||
static String getLocationTitle(Location location) {
|
.apply();
|
||||||
return location == null ? "Requesting location..." : "Location updated at " + DateFormat.getDateTimeInstance().format(new Date(location.getTime()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void startService(Context context) {
|
static void startService(Context context) {
|
||||||
@ -83,28 +84,64 @@ class LocationUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Notification getNotification(Context context, Location location, String channelId) {
|
|
||||||
CharSequence text = LocationUtils.getLocationText(location);
|
|
||||||
|
|
||||||
PendingIntent activityPendingIntent = PendingIntent.getActivity(context, 0,
|
|
||||||
new Intent(context, MainActivity.class), 0);
|
|
||||||
|
|
||||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
|
|
||||||
.addAction(R.drawable.blank_icon, "Open HA Client",
|
|
||||||
activityPendingIntent)
|
|
||||||
.setContentText(text)
|
|
||||||
.setPriority(-1)
|
|
||||||
.setContentTitle(LocationUtils.getLocationTitle(location))
|
|
||||||
.setOngoing(true)
|
|
||||||
.setSmallIcon(R.drawable.mini_icon)
|
|
||||||
.setWhen(System.currentTimeMillis());
|
|
||||||
|
|
||||||
return builder.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void startWorker(Context context, long interval) {
|
static void startWorker(Context context, long interval) {
|
||||||
PeriodicWorkRequest periodicWork = new PeriodicWorkRequest.Builder(LocationUpdatesWorker.class, interval, TimeUnit.MILLISECONDS)
|
PeriodicWorkRequest periodicWork = new PeriodicWorkRequest.Builder(LocationUpdatesWorker.class, interval, TimeUnit.MILLISECONDS)
|
||||||
.build();
|
.build();
|
||||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(LocationUtils.LOCATION_WORK_NAME, ExistingPeriodicWorkPolicy.REPLACE, periodicWork);
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(LocationUtils.LOCATION_WORK_NAME, ExistingPeriodicWorkPolicy.REPLACE, periodicWork);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void requestLocationOnce(Context context) {
|
||||||
|
Intent myService = new Intent(context, LocationRequestService.class);
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(myService);
|
||||||
|
} else {
|
||||||
|
context.startService(myService);
|
||||||
|
}
|
||||||
|
//OneTimeWorkRequest oneTimeWork = new OneTimeWorkRequest.Builder(LocationUpdatesWorker.class)
|
||||||
|
// .build();
|
||||||
|
//WorkManager.getInstance(context).enqueueUniqueWork(LocationUtils.LOCATION_REQUEST_NAME, ExistingWorkPolicy.REPLACE, oneTimeWork);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Notification getNotification(Context context, Location location, String channelId) {
|
||||||
|
CharSequence title = "Location tracking";
|
||||||
|
CharSequence text = location == null ? "Accuracy: unknown" : "Accuracy: " + location.getAccuracy() + " m";
|
||||||
|
CharSequence bigText = location == null ? "Waiting for location..." : "Time: " + DateFormat.getDateTimeInstance().format(new Date(location.getTime())) +
|
||||||
|
System.getProperty("line.separator") + "Accuracy: " + location.getAccuracy() + " m" +
|
||||||
|
System.getProperty("line.separator") + "Location: " + location.getLatitude() + ", " + location.getLongitude();
|
||||||
|
|
||||||
|
PendingIntent activityPendingIntent = PendingIntent.getActivity(context, 0,
|
||||||
|
new Intent(context, MainActivity.class), 0);
|
||||||
|
|
||||||
|
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
|
||||||
|
.setContentIntent(activityPendingIntent)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setStyle(new NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(bigText))
|
||||||
|
.setPriority(-1)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSmallIcon(R.drawable.mini_icon_location)
|
||||||
|
.setWhen(System.currentTimeMillis());
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Notification getRequestNotification(Context context, Location location, String channelId) {
|
||||||
|
CharSequence title = "Updating location...";
|
||||||
|
CharSequence text = location == null ? "Waiting for location..." : "Accuracy: " + location.getAccuracy() + " m";
|
||||||
|
|
||||||
|
PendingIntent activityPendingIntent = PendingIntent.getActivity(context, 0,
|
||||||
|
new Intent(context, MainActivity.class), 0);
|
||||||
|
|
||||||
|
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId)
|
||||||
|
.setContentIntent(activityPendingIntent)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(text)
|
||||||
|
.setPriority(-1)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSmallIcon(R.drawable.mini_icon_location)
|
||||||
|
.setWhen(System.currentTimeMillis());
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import android.content.Context;
|
|||||||
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
|
import android.location.Location;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
import io.flutter.plugin.common.MethodChannel;
|
||||||
@ -29,7 +30,7 @@ public class MainActivity extends FlutterActivity {
|
|||||||
private static final int REQUEST_PERMISSIONS_REQUEST_CODE = 34;
|
private static final int REQUEST_PERMISSIONS_REQUEST_CODE = 34;
|
||||||
|
|
||||||
private int locationUpdatesType = LocationUtils.LOCATION_UPDATES_DISABLED;
|
private int locationUpdatesType = LocationUtils.LOCATION_UPDATES_DISABLED;
|
||||||
private long locationUpdatesInterval = LocationUtils.DEFAULT_LOCATION_UPDATE_INTERVAL_S * 1000;
|
private long locationUpdatesInterval = LocationUtils.DEFAULT_LOCATION_UPDATE_INTERVAL_MS;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
|
public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) {
|
||||||
@ -39,6 +40,7 @@ public class MainActivity extends FlutterActivity {
|
|||||||
Context context = getActivity();
|
Context context = getActivity();
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case "getFCMToken":
|
case "getFCMToken":
|
||||||
|
try {
|
||||||
if (checkPlayServices()) {
|
if (checkPlayServices()) {
|
||||||
FirebaseInstanceId.getInstance().getInstanceId()
|
FirebaseInstanceId.getInstance().getInstanceId()
|
||||||
.addOnCompleteListener(task -> {
|
.addOnCompleteListener(task -> {
|
||||||
@ -59,15 +61,21 @@ public class MainActivity extends FlutterActivity {
|
|||||||
} else {
|
} else {
|
||||||
result.error("google_play_service_error", "Google Play Services unavailable", null);
|
result.error("google_play_service_error", "Google Play Services unavailable", null);
|
||||||
}
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.error("get_token_exception", e.getMessage(), e);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "startLocationService":
|
case "startLocationService":
|
||||||
try {
|
try {
|
||||||
locationUpdatesInterval = LocationUtils.getLocationUpdateIntervals(this);
|
locationUpdatesInterval = ((Number)call.argument("location-updates-interval")).longValue();
|
||||||
if (locationUpdatesInterval >= LocationUtils.MIN_WORKER_LOCATION_UPDATE_INTERVAL_MS) {
|
boolean useForegroundService = (boolean)call.argument("foreground-location-tracking");
|
||||||
locationUpdatesType = LocationUtils.LOCATION_UPDATES_WORKER;
|
|
||||||
} else {
|
if (useForegroundService) {
|
||||||
locationUpdatesType = LocationUtils.LOCATION_UPDATES_SERVICE;
|
locationUpdatesType = LocationUtils.LOCATION_UPDATES_SERVICE;
|
||||||
|
} else {
|
||||||
|
locationUpdatesType = LocationUtils.LOCATION_UPDATES_WORKER;
|
||||||
}
|
}
|
||||||
|
LocationUtils.setLocationUpdatesSettings(this, locationUpdatesInterval, (boolean)call.argument("location-updates-show-notification"));
|
||||||
if (isNoLocationPermissions()) {
|
if (isNoLocationPermissions()) {
|
||||||
requestLocationPermissions();
|
requestLocationPermissions();
|
||||||
} else {
|
} else {
|
||||||
@ -75,12 +83,16 @@ public class MainActivity extends FlutterActivity {
|
|||||||
}
|
}
|
||||||
result.success("");
|
result.success("");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
result.error("location_error", e.getMessage(), null);
|
result.error("location_error", e.getMessage(), e);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "stopLocationService":
|
case "stopLocationService":
|
||||||
|
try {
|
||||||
stopLocationUpdates();
|
stopLocationUpdates();
|
||||||
result.success("");
|
result.success("");
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.error("location_error", e.getMessage(), e);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "cancelOldLocationWorker":
|
case "cancelOldLocationWorker":
|
||||||
WorkManager.getInstance(this).cancelAllWorkByTag("haclocation");
|
WorkManager.getInstance(this).cancelAllWorkByTag("haclocation");
|
||||||
|
@ -22,11 +22,14 @@ import com.google.firebase.messaging.RemoteMessage;
|
|||||||
|
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.graphics.BitmapFactory;
|
import android.graphics.BitmapFactory;
|
||||||
|
import android.util.Log;
|
||||||
import android.webkit.URLUtil;
|
import android.webkit.URLUtil;
|
||||||
|
|
||||||
|
|
||||||
public class MessagingService extends FirebaseMessagingService {
|
public class MessagingService extends FirebaseMessagingService {
|
||||||
|
|
||||||
|
private static final String TAG = MessagingService.class.getSimpleName();
|
||||||
|
|
||||||
public static final String NOTIFICATION_ACTION_BROADCAST = "com.keyboardcrumbs.hassclient.haNotificationAction";
|
public static final String NOTIFICATION_ACTION_BROADCAST = "com.keyboardcrumbs.hassclient.haNotificationAction";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -47,6 +50,16 @@ public class MessagingService extends FirebaseMessagingService {
|
|||||||
private void sendNotification(Map<String, String> data) {
|
private void sendNotification(Map<String, String> data) {
|
||||||
String channelId, messageBody, messageTitle, imageUrl, nTag, channelDescription;
|
String channelId, messageBody, messageTitle, imageUrl, nTag, channelDescription;
|
||||||
boolean autoCancel;
|
boolean autoCancel;
|
||||||
|
if (!data.containsKey("body")) {
|
||||||
|
messageBody = "";
|
||||||
|
} else {
|
||||||
|
messageBody = data.get("body");
|
||||||
|
}
|
||||||
|
if (messageBody != null && messageBody.equals(LocationUtils.REQUEST_LOCATION_NOTIFICATION)) {
|
||||||
|
Log.d(TAG, "Location update request received");
|
||||||
|
LocationUtils.requestLocationOnce(this);
|
||||||
|
return;
|
||||||
|
}
|
||||||
String customChannelId = data.get("channelId");
|
String customChannelId = data.get("channelId");
|
||||||
if (customChannelId == null) {
|
if (customChannelId == null) {
|
||||||
channelId = "ha_notify";
|
channelId = "ha_notify";
|
||||||
@ -55,11 +68,6 @@ public class MessagingService extends FirebaseMessagingService {
|
|||||||
channelId = customChannelId;
|
channelId = customChannelId;
|
||||||
channelDescription = channelId;
|
channelDescription = channelId;
|
||||||
}
|
}
|
||||||
if (!data.containsKey("body")) {
|
|
||||||
messageBody = "";
|
|
||||||
} else {
|
|
||||||
messageBody = data.get("body");
|
|
||||||
}
|
|
||||||
if (!data.containsKey("title")) {
|
if (!data.containsKey("title")) {
|
||||||
messageTitle = "HA Client";
|
messageTitle = "HA Client";
|
||||||
} else {
|
} else {
|
||||||
@ -106,12 +114,16 @@ public class MessagingService extends FirebaseMessagingService {
|
|||||||
.setAutoCancel(autoCancel)
|
.setAutoCancel(autoCancel)
|
||||||
.setSound(defaultSoundUri)
|
.setSound(defaultSoundUri)
|
||||||
.setContentIntent(pendingIntent);
|
.setContentIntent(pendingIntent);
|
||||||
|
Bitmap image = null;
|
||||||
if (URLUtil.isValidUrl(imageUrl)) {
|
if (URLUtil.isValidUrl(imageUrl)) {
|
||||||
Bitmap image = getBitmapFromURL(imageUrl);
|
image = getBitmapFromURL(imageUrl);
|
||||||
|
}
|
||||||
if (image != null) {
|
if (image != null) {
|
||||||
notificationBuilder.setStyle(new NotificationCompat.BigPictureStyle().bigPicture(image).bigLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.blank_icon)));
|
notificationBuilder.setStyle(new NotificationCompat.BigPictureStyle().bigPicture(image).bigLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.blank_icon)));
|
||||||
notificationBuilder.setLargeIcon(image);
|
notificationBuilder.setLargeIcon(image);
|
||||||
}
|
} else {
|
||||||
|
notificationBuilder.setStyle(new NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(messageBody));
|
||||||
}
|
}
|
||||||
for (int i = 1; i <= 3; i++) {
|
for (int i = 1; i <= 3; i++) {
|
||||||
if (data.containsKey("action" + i)) {
|
if (data.containsKey("action" + i)) {
|
||||||
|
@ -4,10 +4,11 @@ import android.content.BroadcastReceiver;
|
|||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
|
||||||
public class LocationUpdatesAfterReboot extends BroadcastReceiver {
|
public class RestartLocationUpdate extends BroadcastReceiver {
|
||||||
|
|
||||||
public void onReceive(Context context, Intent intent) {
|
public void onReceive(Context context, Intent intent) {
|
||||||
if (LocationUtils.getLocationUpdatesState(context) == LocationUtils.LOCATION_UPDATES_SERVICE && Intent.ACTION_BOOT_COMPLETED.equalsIgnoreCase(intent.getAction())) {
|
if (LocationUtils.getLocationUpdatesState(context) == LocationUtils.LOCATION_UPDATES_SERVICE &&
|
||||||
|
(Intent.ACTION_BOOT_COMPLETED.equalsIgnoreCase(intent.getAction()) || Intent.ACTION_MY_PACKAGE_REPLACED.equalsIgnoreCase(intent.getAction()))) {
|
||||||
LocationUtils.startServiceFromBroadcast(context);
|
LocationUtils.startServiceFromBroadcast(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
BIN
android/app/src/main/res/drawable/mini_icon_location.png
Normal file
BIN
android/app/src/main/res/drawable/mini_icon_location.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 571 B |
@ -21,7 +21,6 @@ class CardData {
|
|||||||
switch (rawData['type']) {
|
switch (rawData['type']) {
|
||||||
case CardType.ENTITIES:
|
case CardType.ENTITIES:
|
||||||
case CardType.HISTORY_GRAPH:
|
case CardType.HISTORY_GRAPH:
|
||||||
case CardType.MAP:
|
|
||||||
case CardType.PICTURE_GLANCE:
|
case CardType.PICTURE_GLANCE:
|
||||||
case CardType.SENSOR:
|
case CardType.SENSOR:
|
||||||
case CardType.ENTITY:
|
case CardType.ENTITY:
|
||||||
@ -47,6 +46,9 @@ class CardData {
|
|||||||
return CardData(null);
|
return CardData(null);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case CardType.MAP:
|
||||||
|
return MapCardData(rawData);
|
||||||
|
break;
|
||||||
case CardType.ENTITY_BUTTON:
|
case CardType.ENTITY_BUTTON:
|
||||||
case CardType.BUTTON:
|
case CardType.BUTTON:
|
||||||
case CardType.PICTURE_ENTITY:
|
case CardType.PICTURE_ENTITY:
|
||||||
@ -644,6 +646,52 @@ class MarkdownCardData extends CardData {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MapCardData extends CardData {
|
||||||
|
|
||||||
|
String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildCardWidget() {
|
||||||
|
return MapCard(card: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
MapCardData(rawData) : super(rawData) {
|
||||||
|
//Parsing card data
|
||||||
|
title = rawData['title'];
|
||||||
|
List<dynamic> geoLocationSources = rawData['geo_location_sources'] ?? [];
|
||||||
|
if (geoLocationSources.isNotEmpty) {
|
||||||
|
//TODO add entities by source
|
||||||
|
}
|
||||||
|
var rawEntities = rawData["entities"] ?? [];
|
||||||
|
rawEntities.forEach((rawEntity) {
|
||||||
|
if (rawEntity is String) {
|
||||||
|
if (HomeAssistant().entities.isExist(rawEntity)) {
|
||||||
|
entities.add(EntityWrapper(entity: HomeAssistant().entities.get(rawEntity)));
|
||||||
|
} else {
|
||||||
|
entities.add(EntityWrapper(entity: Entity.missed(rawEntity)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (HomeAssistant().entities.isExist(rawEntity["entity"])) {
|
||||||
|
Entity e = HomeAssistant().entities.get(rawEntity["entity"]);
|
||||||
|
entities.add(
|
||||||
|
EntityWrapper(
|
||||||
|
entity: e,
|
||||||
|
stateColor: stateColor,
|
||||||
|
overrideName: rawEntity["name"]?.toString(),
|
||||||
|
overrideIcon: rawEntity["icon"],
|
||||||
|
stateFilter: rawEntity['state_filter'] ?? [],
|
||||||
|
uiAction: EntityUIAction(rawEntityData: rawEntity)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
entities.add(EntityWrapper(entity: Entity.missed(rawEntity["entity"])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
class MediaControlCardData extends CardData {
|
class MediaControlCardData extends CardData {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
88
lib/cards/map_card.dart
Normal file
88
lib/cards/map_card.dart
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
part of '../main.dart';
|
||||||
|
|
||||||
|
class MapCard extends StatefulWidget {
|
||||||
|
final MapCardData card;
|
||||||
|
|
||||||
|
const MapCard({Key key, this.card}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_MapCardState createState() => _MapCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapCardState extends State<MapCard> {
|
||||||
|
|
||||||
|
void _openMap(BuildContext context) {
|
||||||
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (bc) {
|
||||||
|
return Scaffold(
|
||||||
|
primary: false,
|
||||||
|
/*appBar: new AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
leading: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){
|
||||||
|
Navigator.pop(context);
|
||||||
|
}),
|
||||||
|
actions: <Widget>[
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.fullscreen),
|
||||||
|
onPressed: () {},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
// Here we take the value from the MyHomePage object that was created by
|
||||||
|
// the App.build method, and use it to set our appbar title.
|
||||||
|
title: new Text("${widget.card.title ?? ""}"),
|
||||||
|
),*/
|
||||||
|
body: Container(
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
EntitiesMap(
|
||||||
|
entities: widget.card.entities,
|
||||||
|
interactive: true
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
child: IconButton(icon: Icon(Icons.arrow_back), onPressed: (){
|
||||||
|
Navigator.pop(context);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CardWrapper(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
CardHeader(name: widget.card.title),
|
||||||
|
Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _openMap(context),
|
||||||
|
child: EntitiesMap(
|
||||||
|
aspectRatio: 1,
|
||||||
|
interactive: false,
|
||||||
|
entities: widget.card.entities,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
child: Text('Tap to open interactive map', style: Theme.of(context).textTheme.caption)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
73
lib/cards/widgets/entities_map.dart
Normal file
73
lib/cards/widgets/entities_map.dart
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
part of '../../main.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class EntitiesMap extends StatelessWidget {
|
||||||
|
|
||||||
|
final List<EntityWrapper> entities;
|
||||||
|
final bool interactive;
|
||||||
|
final double aspectRatio;
|
||||||
|
final LatLng center;
|
||||||
|
final double zoom;
|
||||||
|
|
||||||
|
const EntitiesMap({Key key, this.entities: const [], this.aspectRatio, this.interactive: true, this.center, this.zoom}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
List<Marker> markers = [];
|
||||||
|
List<LatLng> points = [];
|
||||||
|
entities.forEach((entityWrapper) {
|
||||||
|
double lat = entityWrapper.entity._getDoubleAttributeValue("latitude");
|
||||||
|
double long = entityWrapper.entity._getDoubleAttributeValue("longitude");
|
||||||
|
if (lat != null && long != null) {
|
||||||
|
points.add(LatLng(lat, long));
|
||||||
|
markers.add(
|
||||||
|
Marker(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
point: LatLng(lat, long),
|
||||||
|
builder: (ctx) => EntityModel(
|
||||||
|
handleTap: true,
|
||||||
|
entityWrapper: entityWrapper,
|
||||||
|
child: EntityIcon(
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
MapOptions mapOptions;
|
||||||
|
if (center != null) {
|
||||||
|
mapOptions = MapOptions(
|
||||||
|
interactive: interactive,
|
||||||
|
center: center,
|
||||||
|
zoom: zoom ?? 10,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
mapOptions = MapOptions(
|
||||||
|
interactive: interactive,
|
||||||
|
bounds: LatLngBounds.fromPoints(points),
|
||||||
|
boundsOptions: FitBoundsOptions(padding: EdgeInsets.all(40)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Widget map = FlutterMap(
|
||||||
|
options: mapOptions,
|
||||||
|
layers: [
|
||||||
|
new TileLayerOptions(
|
||||||
|
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
subdomains: ['a', 'b', 'c']
|
||||||
|
),
|
||||||
|
new MarkerLayerOptions(
|
||||||
|
markers: markers,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (aspectRatio != null) {
|
||||||
|
return AspectRatio(
|
||||||
|
aspectRatio: aspectRatio,
|
||||||
|
child: map
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
@ -278,6 +278,7 @@ class HomeAssistant {
|
|||||||
_rawPanels = data;
|
_rawPanels = data;
|
||||||
List<Panel> dashboards = [];
|
List<Panel> dashboards = [];
|
||||||
data.forEach((k,v) {
|
data.forEach((k,v) {
|
||||||
|
Logger.d('[HA] Panel $k: title=${v['title']}; component=${v['component_name']}');
|
||||||
String title = v['title'] == null ? "${k[0].toUpperCase()}${k.substring(1)}" : "${v['title'][0].toUpperCase()}${v['title'].substring(1)}";
|
String title = v['title'] == null ? "${k[0].toUpperCase()}${k.substring(1)}" : "${v['title'][0].toUpperCase()}${v['title'].substring(1)}";
|
||||||
if (v['component_name'] != null && v['component_name'] == 'lovelace') {
|
if (v['component_name'] != null && v['component_name'] == 'lovelace') {
|
||||||
dashboards.add(
|
dashboards.add(
|
||||||
|
@ -27,12 +27,16 @@ import 'package:flutter_webview_plugin/flutter_webview_plugin.dart' as standalon
|
|||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import 'package:syncfusion_flutter_core/core.dart';
|
import 'package:syncfusion_flutter_core/core.dart';
|
||||||
import 'package:syncfusion_flutter_gauges/gauges.dart';
|
import 'package:syncfusion_flutter_gauges/gauges.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong/latlong.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
|
||||||
import 'utils/logger.dart';
|
import 'utils/logger.dart';
|
||||||
import '.secrets.dart';
|
import '.secrets.dart';
|
||||||
|
|
||||||
part 'const.dart';
|
part 'const.dart';
|
||||||
part 'utils/launcher.dart';
|
part 'utils/launcher.dart';
|
||||||
|
part 'utils/RandomColorGenerator.dart';
|
||||||
part 'entities/entity.class.dart';
|
part 'entities/entity.class.dart';
|
||||||
part 'entities/entity_wrapper.class.dart';
|
part 'entities/entity_wrapper.class.dart';
|
||||||
part 'entities/timer/timer_entity.class.dart';
|
part 'entities/timer/timer_entity.class.dart';
|
||||||
@ -45,6 +49,7 @@ part 'entities/date_time/date_time_entity.class.dart';
|
|||||||
part 'entities/light/light_entity.class.dart';
|
part 'entities/light/light_entity.class.dart';
|
||||||
part 'entities/select/select_entity.class.dart';
|
part 'entities/select/select_entity.class.dart';
|
||||||
part 'entities/sun/sun_entity.class.dart';
|
part 'entities/sun/sun_entity.class.dart';
|
||||||
|
part 'cards/widgets/entities_map.dart';
|
||||||
part 'entities/sensor/sensor_entity.class.dart';
|
part 'entities/sensor/sensor_entity.class.dart';
|
||||||
part 'entities/slider/slider_entity.dart';
|
part 'entities/slider/slider_entity.dart';
|
||||||
part 'entities/media_player/media_player_entity.class.dart';
|
part 'entities/media_player/media_player_entity.class.dart';
|
||||||
@ -58,6 +63,7 @@ part 'entities/entity_model.widget.dart';
|
|||||||
part 'entities/default_entity_container.widget.dart';
|
part 'entities/default_entity_container.widget.dart';
|
||||||
part 'entities/missed_entity.widget.dart';
|
part 'entities/missed_entity.widget.dart';
|
||||||
part 'cards/entity_button_card.dart';
|
part 'cards/entity_button_card.dart';
|
||||||
|
part 'cards/map_card.dart';
|
||||||
part 'pages/widgets/entity_attributes_list.dart';
|
part 'pages/widgets/entity_attributes_list.dart';
|
||||||
part 'entities/entity_icon.widget.dart';
|
part 'entities/entity_icon.widget.dart';
|
||||||
part 'entities/entity_name.widget.dart';
|
part 'entities/entity_name.widget.dart';
|
||||||
@ -154,7 +160,7 @@ part 'managers/app_settings.dart';
|
|||||||
EventBus eventBus = new EventBus();
|
EventBus eventBus = new EventBus();
|
||||||
const String appName = 'HA Client';
|
const String appName = 'HA Client';
|
||||||
const String appVersion = String.fromEnvironment('versionName', defaultValue: '0.0.0');
|
const String appVersion = String.fromEnvironment('versionName', defaultValue: '0.0.0');
|
||||||
const whatsNewUrl = 'http://ha-client.app/service/whats_new_1.2.0.md';
|
const whatsNewUrl = 'http://ha-client.app/service/whats_new_1.3.0.md';
|
||||||
|
|
||||||
Future<void> _reportError(dynamic error, dynamic stackTrace) async {
|
Future<void> _reportError(dynamic error, dynamic stackTrace) async {
|
||||||
// Print the exception to the console.
|
// Print the exception to the console.
|
||||||
|
@ -33,7 +33,6 @@ class AppSettings {
|
|||||||
bool nextAlarmSensorCreated = false;
|
bool nextAlarmSensorCreated = false;
|
||||||
DisplayMode displayMode;
|
DisplayMode displayMode;
|
||||||
AppTheme appTheme;
|
AppTheme appTheme;
|
||||||
final int defaultLocationUpdateIntervalSeconds = 900;
|
|
||||||
|
|
||||||
bool get isAuthenticated => longLivedToken != null;
|
bool get isAuthenticated => longLivedToken != null;
|
||||||
bool get isTempAuthenticated => tempToken != null;
|
bool get isTempAuthenticated => tempToken != null;
|
||||||
@ -75,21 +74,22 @@ class AppSettings {
|
|||||||
bool oldLocationTrackingEnabled = prefs.getBool("location-enabled") ?? false;
|
bool oldLocationTrackingEnabled = prefs.getBool("location-enabled") ?? false;
|
||||||
if (oldLocationTrackingEnabled) {
|
if (oldLocationTrackingEnabled) {
|
||||||
await platform.invokeMethod('cancelOldLocationWorker');
|
await platform.invokeMethod('cancelOldLocationWorker');
|
||||||
await prefs.setInt("location-updates-state", 2); //Setting new location tracking mode to worker
|
|
||||||
await prefs.setInt("location-updates-priority", 100); //Setting location accuracy to high
|
|
||||||
int oldLocationTrackingInterval = prefs.getInt("location-interval") ?? 0;
|
int oldLocationTrackingInterval = prefs.getInt("location-interval") ?? 0;
|
||||||
if (oldLocationTrackingInterval < 15) {
|
if (oldLocationTrackingInterval < 15) {
|
||||||
oldLocationTrackingInterval = 15;
|
oldLocationTrackingInterval = 15;
|
||||||
}
|
}
|
||||||
await prefs.setInt("location-updates-interval", oldLocationTrackingInterval * 60); //moving old interval in minutes to new interval in seconds
|
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('startLocationService');
|
await platform.invokeMethod('startLocationService', <String, dynamic>{
|
||||||
} catch (e) {
|
'location-updates-interval': oldLocationTrackingInterval * 60 * 1000,
|
||||||
await prefs.setInt("location-updates-state", 0); //Disabling location tracking if can't start
|
//'location-updates-priority': 100,
|
||||||
|
'location-updates-show-notification': true,
|
||||||
|
'foreground-location-tracking': false
|
||||||
|
});
|
||||||
|
} catch (e, stack) {
|
||||||
|
Logger.e("[MIGRATION] Can't start new location tracking: $e", stacktrace: stack);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.d("[MIGRATION] Old location tracking was disabled");
|
Logger.d("[MIGRATION] Old location tracking was disabled");
|
||||||
await prefs.setInt("location-updates-state", 0); //Setting new location tracking mode to disabled
|
|
||||||
}
|
}
|
||||||
await prefs.setBool("location-tracking-migrated", true);
|
await prefs.setBool("location-tracking-migrated", true);
|
||||||
}
|
}
|
||||||
|
@ -408,7 +408,7 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver, Ticker
|
|||||||
|
|
||||||
int currentViewCount = HomeAssistant().ui?.views?.length ?? 0;
|
int currentViewCount = HomeAssistant().ui?.views?.length ?? 0;
|
||||||
if (_previousViewCount != currentViewCount) {
|
if (_previousViewCount != currentViewCount) {
|
||||||
Logger.d("Views count changed ($_previousViewCount->$currentViewCount). Creating new tabs controller.");
|
//Logger.d("Views count changed ($_previousViewCount->$currentViewCount). Creating new tabs controller.");
|
||||||
_viewsTabController = TabController(vsync: this, length: currentViewCount);
|
_viewsTabController = TabController(vsync: this, length: currentViewCount);
|
||||||
_previousViewCount = currentViewCount;
|
_previousViewCount = currentViewCount;
|
||||||
}
|
}
|
||||||
|
@ -12,18 +12,17 @@ class IntegrationSettingsPage extends StatefulWidget {
|
|||||||
class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
||||||
|
|
||||||
static const platform = const MethodChannel('com.keyboardcrumbs.hassclient/native');
|
static const platform = const MethodChannel('com.keyboardcrumbs.hassclient/native');
|
||||||
static final locationAccuracy = {
|
/*static final locationAccuracy = {
|
||||||
100: "Highest",
|
100: "High",
|
||||||
102: "Balanced (about 100 meters)",
|
102: "Balanced"
|
||||||
104: "Low (up to 10 kilometers)",
|
};*/
|
||||||
105: "Passive (last known location)",
|
|
||||||
};
|
|
||||||
|
|
||||||
Duration _locationInterval;
|
Duration _locationInterval;
|
||||||
bool _locationTrackingEnabled = false;
|
bool _locationTrackingEnabled = false;
|
||||||
bool _wait = false;
|
bool _wait = false;
|
||||||
bool _showNotification = true;
|
bool _showNotification = true;
|
||||||
int _accuracy = 102;
|
//int _accuracy = 100;
|
||||||
|
bool _useForegroundService = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -37,11 +36,12 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
|||||||
await prefs.reload();
|
await prefs.reload();
|
||||||
SharedPreferences.getInstance().then((prefs) {
|
SharedPreferences.getInstance().then((prefs) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_accuracy = prefs.getInt("location-updates-priority") ?? 102;
|
//_accuracy = prefs.getInt("location-updates-priority") ?? 100;
|
||||||
_locationTrackingEnabled = (prefs.getInt("location-updates-state") ?? 0) > 0;
|
_locationTrackingEnabled = (prefs.getInt("location-updates-state") ?? 0) > 0;
|
||||||
_showNotification = prefs.getBool("location-updates-show-notification") ?? true;
|
_showNotification = prefs.getBool("location-updates-show-notification") ?? true;
|
||||||
_locationInterval = Duration(seconds: prefs.getInt("location-updates-interval") ??
|
_useForegroundService = prefs.getBool("foreground-location-tracking") ?? false;
|
||||||
AppSettings().defaultLocationUpdateIntervalSeconds);
|
_locationInterval = Duration(milliseconds: prefs.getInt("location-updates-interval") ??
|
||||||
|
900000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -71,36 +71,32 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _decLocationInterval() {
|
void _decLocationInterval() {
|
||||||
if (_locationInterval.inSeconds > 5) {
|
if ((_useForegroundService && _locationInterval.inSeconds > 5) || (!_useForegroundService && _locationInterval.inMinutes > 15)) {
|
||||||
|
setState(() {
|
||||||
if (_locationInterval.inSeconds <= 60) {
|
if (_locationInterval.inSeconds <= 60) {
|
||||||
setState(() {
|
|
||||||
_locationInterval = _locationInterval - Duration(seconds: 5);
|
_locationInterval = _locationInterval - Duration(seconds: 5);
|
||||||
});
|
|
||||||
} else if (_locationInterval.inMinutes <= 15) {
|
} else if (_locationInterval.inMinutes <= 15) {
|
||||||
setState(() {
|
|
||||||
_locationInterval = _locationInterval - Duration(minutes: 1);
|
_locationInterval = _locationInterval - Duration(minutes: 1);
|
||||||
});
|
|
||||||
} else if (_locationInterval.inMinutes <= 60) {
|
} else if (_locationInterval.inMinutes <= 60) {
|
||||||
setState(() {
|
|
||||||
_locationInterval = _locationInterval - Duration(minutes: 5);
|
_locationInterval = _locationInterval - Duration(minutes: 5);
|
||||||
});
|
|
||||||
} else if (_locationInterval.inHours <= 4) {
|
} else if (_locationInterval.inHours <= 4) {
|
||||||
setState(() {
|
|
||||||
_locationInterval = _locationInterval - Duration(minutes: 10);
|
_locationInterval = _locationInterval - Duration(minutes: 10);
|
||||||
});
|
|
||||||
} else if (_locationInterval.inHours > 4) {
|
} else if (_locationInterval.inHours > 4) {
|
||||||
setState(() {
|
|
||||||
_locationInterval = _locationInterval - Duration(hours: 1);
|
_locationInterval = _locationInterval - Duration(hours: 1);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_switchLocationTrackingState(bool state) async {
|
_switchLocationTrackingState(bool state) async {
|
||||||
await AppSettings().save({'location-updates-interval': _locationInterval.inSeconds, 'location-updates-priority': _accuracy, 'location-updates-show-notification': _showNotification});
|
|
||||||
if (state) {
|
if (state) {
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('startLocationService');
|
await platform.invokeMethod('startLocationService', <String, dynamic>{
|
||||||
|
'location-updates-interval': _locationInterval.inMilliseconds,
|
||||||
|
//'location-updates-priority': _accuracy,
|
||||||
|
'foreground-location-tracking': _useForegroundService,
|
||||||
|
'location-updates-show-notification': _showNotification
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_locationTrackingEnabled = false;
|
_locationTrackingEnabled = false;
|
||||||
}
|
}
|
||||||
@ -143,11 +139,13 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
|||||||
if (_locationTrackingEnabled) {
|
if (_locationTrackingEnabled) {
|
||||||
notes.add(_getNoteWidget('* Stop location tracking to change settings', false));
|
notes.add(_getNoteWidget('* Stop location tracking to change settings', false));
|
||||||
}
|
}
|
||||||
if ((_locationInterval?.inMinutes ?? 15) < 15) {
|
if (_useForegroundService) {
|
||||||
notes.add(_getNoteWidget('* Notification is mandatory for location updates with interval less than every 15 minutes', false));
|
notes.add(_getNoteWidget('* Notification is mandatory for foreground service', false));
|
||||||
if (_accuracy < 102) {
|
} else {
|
||||||
notes.add(_getNoteWidget('* Battery consumption will be noticeable', true));
|
notes.add(_getNoteWidget('* Use foreground service for intervals less then 15 minutes', false));
|
||||||
}
|
}
|
||||||
|
if (_useForegroundService && _locationInterval.inMinutes < 10) {
|
||||||
|
notes.add(_getNoteWidget('* Battery consumption will be noticeable', true));
|
||||||
}
|
}
|
||||||
if (notes.isEmpty) {
|
if (notes.isEmpty) {
|
||||||
return Container(width: 0, height: 0);
|
return Container(width: 0, height: 0);
|
||||||
@ -183,7 +181,26 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
Container(height: Sizes.rowPadding),
|
Container(height: Sizes.rowPadding),
|
||||||
Text("Accuracy:", style: Theme.of(context).textTheme.body2),
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Text("Use foreground service"),
|
||||||
|
Switch(
|
||||||
|
value: _useForegroundService,
|
||||||
|
onChanged: _locationTrackingEnabled ? null : (value) {
|
||||||
|
setState(() {
|
||||||
|
_useForegroundService = value;
|
||||||
|
if (!_useForegroundService && _locationInterval.inMinutes < 15) {
|
||||||
|
_locationInterval = Duration(minutes: 15);
|
||||||
|
} else if (_useForegroundService) {
|
||||||
|
_showNotification = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(height: Sizes.rowPadding),
|
||||||
|
/*Text("Accuracy:", style: Theme.of(context).textTheme.body2),
|
||||||
Container(height: Sizes.rowPadding),
|
Container(height: Sizes.rowPadding),
|
||||||
DropdownButton<int>(
|
DropdownButton<int>(
|
||||||
value: _accuracy,
|
value: _accuracy,
|
||||||
@ -202,7 +219,7 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Container(height: Sizes.rowPadding),
|
Container(height: Sizes.rowPadding),*/
|
||||||
Text("Update interval"),
|
Text("Update interval"),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@ -232,7 +249,7 @@ class _IntegrationSettingsPageState extends State<IntegrationSettingsPage> {
|
|||||||
Text("Show notification"),
|
Text("Show notification"),
|
||||||
Switch(
|
Switch(
|
||||||
value: _showNotification,
|
value: _showNotification,
|
||||||
onChanged: (_locationTrackingEnabled || (_locationInterval?.inMinutes ?? 0) < 15) ? null : (value) {
|
onChanged: (_locationTrackingEnabled || _useForegroundService) ? null : (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_showNotification = value;
|
_showNotification = value;
|
||||||
});
|
});
|
||||||
|
@ -39,7 +39,7 @@ class Panel {
|
|||||||
eventBus.fire(ReloadUIEvent());
|
eventBus.fire(ReloadUIEvent());
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Launcher.launchAuthenticatedWebView(context: context, url: "${AppSettings().httpWebHost}/$urlPath", title: "${this.title}");
|
Launcher.launchAuthenticatedWebView(context: context, url: "${AppSettings().httpWebHost}/$urlPath", title: "Back to app");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
28
lib/utils/RandomColorGenerator.dart
Normal file
28
lib/utils/RandomColorGenerator.dart
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
part of '../main.dart';
|
||||||
|
|
||||||
|
class RandomColorGenerator {
|
||||||
|
static const colorsList = [
|
||||||
|
Colors.green,
|
||||||
|
Colors.purple,
|
||||||
|
Colors.indigo,
|
||||||
|
Colors.red,
|
||||||
|
Colors.orange,
|
||||||
|
Colors.cyan
|
||||||
|
];
|
||||||
|
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
Color getCurrent() {
|
||||||
|
return colorsList[_index];
|
||||||
|
}
|
||||||
|
|
||||||
|
Color getNext() {
|
||||||
|
if (_index < colorsList.length - 1) {
|
||||||
|
_index += 1;
|
||||||
|
} else {
|
||||||
|
_index = 1;
|
||||||
|
}
|
||||||
|
return getCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -74,7 +74,7 @@ class HAView {
|
|||||||
|
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ViewWidget(
|
return ViewWidget(
|
||||||
view: this,
|
view: this
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
name: hass_client
|
name: hass_client
|
||||||
description: Home Assistant Android Client
|
description: Home Assistant Android Client
|
||||||
|
|
||||||
version: 1.3.0+1300
|
version: 1.3.0+1309
|
||||||
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@ -27,8 +27,9 @@ dependencies:
|
|||||||
hive_flutter: ^0.3.0+2
|
hive_flutter: ^0.3.0+2
|
||||||
device_info: ^0.4.2+4
|
device_info: ^0.4.2+4
|
||||||
firebase_crashlytics: ^0.1.3+3
|
firebase_crashlytics: ^0.1.3+3
|
||||||
syncfusion_flutter_core: ^18.1.52
|
syncfusion_flutter_core: ^18.2.44
|
||||||
syncfusion_flutter_gauges: ^18.1.52
|
syncfusion_flutter_gauges: ^18.2.44
|
||||||
|
flutter_map: ^0.10.1
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
Reference in New Issue
Block a user