/*
* AndFHEM - Open Source Android application to control a FHEM home automation
* server.
*
* Copyright (c) 2011, Matthias Klass or third-party contributors as
* indicated by the @author tags or express copyright attribution
* statements applied by the authors. All third-party contributions are
* distributed under license by Red Hat Inc.
*
* This copyrighted material is made available to anyone wishing to use, modify,
* copy, or redistribute it subject to the terms and conditions of the GNU GENERAL PUBLIC LICENSE, as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU GENERAL PUBLIC LICENSE
* for more details.
*
* You should have received a copy of the GNU GENERAL PUBLIC LICENSE
* along with this distribution; if not, write to:
* Free Software Foundation, Inc.
* 51 Franklin Street, Fifth Floor
* Boston, MA 02110-1301 USA
*/
package li.klass.fhem.service.room;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.os.ResultReceiver;
import com.google.common.base.Optional;
import com.google.common.collect.Sets;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.inject.Inject;
import javax.inject.Singleton;
import li.klass.fhem.appwidget.service.AppWidgetUpdateService;
import li.klass.fhem.constants.Actions;
import li.klass.fhem.constants.PreferenceKeys;
import li.klass.fhem.constants.ResultCodes;
import li.klass.fhem.domain.FHEMWEBDevice;
import li.klass.fhem.domain.core.DeviceType;
import li.klass.fhem.domain.core.FhemDevice;
import li.klass.fhem.domain.core.RoomDeviceList;
import li.klass.fhem.service.AbstractService;
import li.klass.fhem.service.connection.ConnectionService;
import li.klass.fhem.service.intent.NotificationIntentService;
import li.klass.fhem.service.intent.RoomListUpdateIntentService;
import li.klass.fhem.util.ApplicationProperties;
import static com.google.common.collect.Lists.newArrayList;
import static java.util.Collections.sort;
import static li.klass.fhem.constants.Actions.DISMISS_EXECUTING_DIALOG;
import static li.klass.fhem.constants.Actions.DO_REMOTE_UPDATE;
import static li.klass.fhem.constants.Actions.DO_UPDATE;
import static li.klass.fhem.constants.Actions.NOTIFICATION_TRIGGER;
import static li.klass.fhem.constants.Actions.REDRAW_ALL_WIDGETS;
import static li.klass.fhem.constants.BundleExtraKeys.ALLOW_REMOTE_UPDATES;
import static li.klass.fhem.constants.BundleExtraKeys.CONNECTION_ID;
import static li.klass.fhem.constants.BundleExtraKeys.DEVICE;
import static li.klass.fhem.constants.BundleExtraKeys.DEVICE_NAME;
import static li.klass.fhem.constants.BundleExtraKeys.DO_REFRESH;
import static li.klass.fhem.constants.BundleExtraKeys.RESEND_TRY;
import static li.klass.fhem.constants.BundleExtraKeys.RESULT_RECEIVER;
import static li.klass.fhem.constants.BundleExtraKeys.ROOM_NAME;
import static li.klass.fhem.constants.BundleExtraKeys.UPDATE_MAP;
import static li.klass.fhem.constants.BundleExtraKeys.UPDATE_PERIOD;
import static li.klass.fhem.constants.BundleExtraKeys.VIBRATE;
import static li.klass.fhem.domain.core.DeviceType.AT;
import static li.klass.fhem.domain.core.DeviceType.getDeviceTypeFor;
import static li.klass.fhem.util.DateFormatUtil.toReadable;
@Singleton
public class RoomListService extends AbstractService {
private static final Logger LOG = LoggerFactory.getLogger(RoomListService.class);
static final String PREFERENCES_NAME = RoomListService.class.getName();
static final String LAST_UPDATE_PROPERTY = "LAST_UPDATE";
public static final long NEVER_UPDATE_PERIOD = 0;
public static final long ALWAYS_UPDATE_PERIOD = -1;
private static final String SORT_ROOMS_DELIMITER = " ";
private final AtomicBoolean remoteUpdateInProgress = new AtomicBoolean(false);
private List<Intent> resendIntents = newArrayList();
@Inject
ConnectionService connectionService;
@Inject
DeviceListParser deviceListParser;
@Inject
ApplicationProperties applicationProperties;
@Inject
RoomListHolderService roomListHolderService;
@Inject
public RoomListService() {
}
public void parseReceivedDeviceStateMap(String deviceName, Map<String, String> updateMap,
boolean vibrateUponNotification, Context context) {
Optional<FhemDevice> deviceOptional = getDeviceForName(deviceName, Optional.<String>absent(), context);
if (!deviceOptional.isPresent()) {
return;
}
FhemDevice device = deviceOptional.get();
deviceListParser.fillDeviceWith(device, updateMap, context);
LOG.info("parseReceivedDeviceStateMap() : updated {} with {} new values!", device.getName(), updateMap.size());
context.startService(new Intent(NOTIFICATION_TRIGGER)
.setClass(context, NotificationIntentService.class)
.putExtra(DEVICE_NAME, deviceName)
.putExtra(DEVICE, device)
.putExtra(UPDATE_MAP, (Serializable) updateMap)
.putExtra(VIBRATE, vibrateUponNotification));
context.sendBroadcast(new Intent(DO_UPDATE));
boolean updateWidgets = applicationProperties.getBooleanSharedPreference(PreferenceKeys.GCM_WIDGET_UPDATE, false, context);
if (updateWidgets) {
context.startService(new Intent(Actions.REDRAW_ALL_WIDGETS)
.setClass(context, AppWidgetUpdateService.class));
}
}
/**
* Looks for a device with a given name.
*
* @param deviceName name of the device
* @return found device or null
*/
@SuppressWarnings("unchecked")
public <T extends FhemDevice> Optional<T> getDeviceForName(String deviceName, Optional<String> connectionId, Context context) {
return Optional.fromNullable((T) getAllRoomsDeviceList(connectionId, context).getDeviceFor(deviceName));
}
/**
* Retrieves a {@link RoomDeviceList} containing all devices, not only the devices of a specific room.
* The room device list will be a copy of the actual one. Thus, any modifications will have no effect!
*
* @return {@link RoomDeviceList} containing all devices
*/
public RoomDeviceList getAllRoomsDeviceList(Optional<String> connectionId, Context context) {
Optional<RoomDeviceList> originalRoomDeviceList = getRoomDeviceList(connectionId, context);
return new RoomDeviceList(originalRoomDeviceList.orNull(), context);
}
/**
* Loads the currently cached {@link li.klass.fhem.domain.core.RoomDeviceList}. If the cached
* device list has not yet been loaded, it will be loaded from the cache object.
* <p/>
* <p>Watch out: Any modifications will be saved within the internal representation. Don't use
* this method from client code!</p>
*
* @return Currently cached {@link li.klass.fhem.domain.core.RoomDeviceList}.
* @param context context
*/
public Optional<RoomDeviceList> getRoomDeviceList(Optional<String> connectionId, Context context) {
return roomListHolderService.getCachedRoomDeviceListMap(connectionId, context);
}
public void resetUpdateProgress(Context context) {
LOG.debug("resetUpdateProgress()");
remoteUpdateInProgress.set(false);
resendIntents = newArrayList();
sendBroadcastWithAction(DISMISS_EXECUTING_DIALOG, context);
}
/**
* Method will check if a remote update of the current device list is required. This is
* determined by two indicators:
* <ul>
* <li>After loading the cached device map, the cached map is still null. Effectively
* this means that no devices had been cached.</li>
* <li>The update period indicates that we have to update the device map.</li>
* </ul>
* <p/>
* <p>
* When finding out that we have to remotely update the device list, the current request
* (as intent) is cached and an intent to {@link li.klass.fhem.service.intent.RoomListUpdateIntentService}
* is sent. When the remote update completes, we will get an answer effectively calling
* {#remoteUpdateFinished}. The method will resent all cached intents, resulting in
* answers to waiting requests.
* </p>
* <p>
* By caching calling intents that all want to remotely load device lists, we make
* sure that only one remote update is concurrently executed. Also, we do not queue
* remote requests, as they would load the same content from the server.
* </p>
*
* @param intent calling intent (that might request a remote update)
* @param updatePeriod update period (that might indicate a necessary remote update)
* @return {@link li.klass.fhem.service.room.RoomListService.RemoteUpdateRequired#REQUIRED} if the
* calling intent will be cached and resend when the remote update has completed, otherwise
* {@link li.klass.fhem.service.room.RoomListService.RemoteUpdateRequired#NOT_REQUIRED}
*/
public RemoteUpdateRequired updateRoomDeviceListIfRequired(Intent intent, long updatePeriod, Context context) {
Optional<String> connectionId = Optional.fromNullable(intent.getStringExtra(CONNECTION_ID));
boolean connectionExists = connectionService.exists(connectionId, context);
boolean hasDevice = intent.hasExtra(DEVICE_NAME);
boolean requiresUpdate = connectionExists && shouldUpdate(updatePeriod, connectionId, context, hasDevice);
if (requiresUpdate) {
LOG.info("updateRoomDeviceListIfRequired() - requiring update, add pending action: {}", intent.getAction());
resendIntents.add(createResendIntent(intent));
if (hasDevice || remoteUpdateInProgress.compareAndSet(false, true)) {
context.startService(new Intent(DO_REMOTE_UPDATE)
.putExtra(DEVICE_NAME, intent.getStringExtra(DEVICE_NAME))
.putExtra(ROOM_NAME, intent.getStringExtra(ROOM_NAME))
.putExtra(CONNECTION_ID, connectionId.orNull())
.setClass(context, RoomListUpdateIntentService.class));
}
LOG.debug("updateRoomDeviceListIfRequired() - remote update is required");
return RemoteUpdateRequired.REQUIRED;
} else if (remoteUpdateInProgress.get()) {
resendIntents.add(createResendIntent(intent));
return RemoteUpdateRequired.REQUIRED;
} else {
LOG.debug("updateRoomDeviceListIfRequired() - remote update is not required");
return RemoteUpdateRequired.NOT_REQUIRED;
}
}
/**
* Entry point for completed remote updates. See {@link #updateRoomDeviceListIfRequired} for
* details on the process.
*/
public void remoteUpdateFinished(Context context, boolean success) {
try {
LOG.info("remoteUpdateFinished() - starting after actions");
if (success) {
LOG.info("remoteUpdateFinished() - requesting redraw of all appwidgets");
for (Intent resendIntent : resendIntents) {
resend(resendIntent, context);
}
context.sendBroadcast(new Intent(DO_UPDATE)
.putExtra(DO_REFRESH, false));
context.startService(new Intent(REDRAW_ALL_WIDGETS)
.setClass(context, AppWidgetUpdateService.class)
.putExtra(ALLOW_REMOTE_UPDATES, false));
LOG.info("remoteUpdateFinished() - remote update finished, device list is {}");
} else {
LOG.info("remoteUpdateFinished() - update was not successful");
for (Intent resendIntent : resendIntents) {
answerError(resendIntent);
}
}
resendIntents.clear();
} finally {
LOG.info("remoteUpdateFinished() - finished, dismissing executing dialog");
resetUpdateProgress(context);
}
}
private void answerError(Intent resendIntent) {
ResultReceiver receiver = resendIntent.getParcelableExtra(RESULT_RECEIVER);
if (receiver != null) {
receiver.send(ResultCodes.ERROR, new Bundle());
}
}
private void resend(Intent intent, Context context) {
LOG.info("resend() : resending {}", intent.getAction());
if (intent.getIntExtra(RESEND_TRY, 0) > 2) {
if (intent.hasExtra(RESULT_RECEIVER)) {
ResultReceiver receiver = intent.getParcelableExtra(RESULT_RECEIVER);
receiver.send(ResultCodes.ERROR, new Bundle());
LOG.error("resend() - exceeds maximum attempts, sending error");
}
} else {
context.startService(intent);
}
}
private Intent createResendIntent(Intent intent) {
Intent resendIntent = new Intent(intent);
resendIntent.removeExtra(DO_REFRESH);
resendIntent.removeExtra(UPDATE_PERIOD);
resendIntent.putExtra(UPDATE_PERIOD, NEVER_UPDATE_PERIOD);
resendIntent.putExtra(RESEND_TRY, intent.getIntExtra(RESEND_TRY, 0) + 1);
return resendIntent;
}
private boolean shouldUpdate(long updatePeriod, Optional<String> connectionId, Context context, boolean hasDevice) {
if (updatePeriod == ALWAYS_UPDATE_PERIOD) {
LOG.debug("shouldUpdate() : recommend update, as updatePeriod is set to ALWAYS_UPDATE");
return true;
}
if (updatePeriod == NEVER_UPDATE_PERIOD) {
LOG.debug("shouldUpdate() : recommend no update, as updatePeriod is set to NEVER_UPDATE");
return false;
} else if (hasDevice) {
LOG.debug("shouldUpdate() : has explicit device => update always");
return true;
}
long lastUpdate = getLastUpdate(connectionId, context);
boolean shouldUpdate = lastUpdate + updatePeriod < System.currentTimeMillis();
LOG.debug("shouldUpdate() : recommend {} update (lastUpdate: {}, updatePeriod: {} min)", (!shouldUpdate ? "no " : "to"), toReadable(lastUpdate), (updatePeriod / 1000 / 60));
return shouldUpdate;
}
public long getLastUpdate(Optional<String> connectionId, Context context) {
return roomListHolderService.getLastUpdate(connectionId, context);
}
public ArrayList<String> getAvailableDeviceNames(Optional<String> connectionId, Context context) {
ArrayList<String> deviceNames = newArrayList();
RoomDeviceList allRoomsDeviceList = getAllRoomsDeviceList(connectionId, context);
for (FhemDevice device : allRoomsDeviceList.getAllDevices()) {
deviceNames.add(device.getName() + "|" +
emptyOrValue(device.getAlias()) + "|" +
emptyOrValue(device.getWidgetName()));
}
return deviceNames;
}
private String emptyOrValue(String value) {
if (value == null) return "";
return value;
}
/**
* Retrieves a list of all room names.
*
* @param context context
* @return list of all room names
*/
public ArrayList<String> getRoomNameList(Optional<String> connectionId, Context context) {
Optional<RoomDeviceList> roomDeviceList = getRoomDeviceList(connectionId, context);
if (!roomDeviceList.isPresent()) return newArrayList();
Set<String> roomNames = Sets.newHashSet();
for (FhemDevice device : roomDeviceList.get().getAllDevices()) {
DeviceType type = getDeviceTypeFor(device);
if (type == null) {
continue;
}
if (device.isSupported() && connectionService.mayShowInCurrentConnectionType(type, context) && type != AT) {
//noinspection unchecked
roomNames.addAll(device.getRooms());
}
}
roomNames.removeAll(roomDeviceList.get().getHiddenRooms());
FHEMWEBDevice fhemwebDevice = roomListHolderService.findFHEMWEBDevice(roomDeviceList.get(), context);
return sortRooms(roomNames, fhemwebDevice);
}
private ArrayList<String> sortRooms(Set<String> roomNames, FHEMWEBDevice fhemwebDevice) {
final List<String> sortRooms = newArrayList();
if (fhemwebDevice != null && fhemwebDevice.getSortRooms() != null) {
sortRooms.addAll(Arrays.asList(fhemwebDevice.getSortRooms().split(SORT_ROOMS_DELIMITER)));
}
ArrayList<String> roomNamesCopy = newArrayList(roomNames);
sort(roomNamesCopy, sortRoomsComparator(sortRooms));
return roomNamesCopy;
}
private Comparator<String> sortRoomsComparator(final List<String> sortRooms) {
return new Comparator<String>() {
@Override
public int compare(String lhs, String rhs) {
int lhsIndex = sortRooms.indexOf(lhs);
int rhsIndex = sortRooms.indexOf(rhs);
if (lhsIndex == rhsIndex && lhsIndex == -1) {
// both not in sort list, compare based on names
return lhs.compareTo(rhs);
} else if (lhsIndex != rhsIndex && lhsIndex != -1 && rhsIndex != -1) {
// both in sort list, compare indexes
return ((Integer) lhsIndex).compareTo(rhsIndex);
} else if (lhsIndex == -1) {
// lhs not in sort list, rhs in sort list
return 1;
} else {
// rhs not in sort list, lhs in sort list
return -1;
}
}
};
}
/**
* Retrieves the {@link RoomDeviceList} for a specific room name.
*
* @param roomName room name used for searching.
* @return found {@link RoomDeviceList} or null
*/
public RoomDeviceList getDeviceListForRoom(String roomName, Optional<String> connectionId, Context context) {
RoomDeviceList roomDeviceList = new RoomDeviceList(roomName);
Optional<RoomDeviceList> allRoomDeviceList = getRoomDeviceList(connectionId, context);
if (allRoomDeviceList.isPresent()) {
for (FhemDevice device : allRoomDeviceList.get().getAllDevices()) {
if (device.isInRoom(roomName)) {
roomDeviceList.addDevice(device, context);
}
}
roomDeviceList.setHiddenGroups(allRoomDeviceList.get().getHiddenGroups());
roomDeviceList.setHiddenRooms(allRoomDeviceList.get().getHiddenRooms());
}
return roomDeviceList;
}
public void clearDeviceList(Optional<String> connectionId, Context context) {
roomListHolderService.clearRoomDeviceList(connectionId, context);
}
public enum RemoteUpdateRequired {
REQUIRED, NOT_REQUIRED
}
}