[icloud] Rework authentication to reflect changes in iCloud API (#13691)

* Implement Authentication (WIP)
* Validation Code accepted
* Refactor session state
* RefreshClient working
* Implement session persistence in openhab store
* Integration in binding
* Remove persistent cookies, which break authentication
* Bugfixing
* Add code configuration to UI
* Improve documentation, error-handling and cleanup
* Rework auth order
* Rework auth process
* Add 2-FA-auth to documentation
* Set bridge to online if data refresh works
* Case-sensitive rename ICloudAPIResponseException
* Include authentication in refresh flow
* Fix regression for data not being updated
* Fix typo in i18n props
* Fix review and checkstyle.
* More javadoc, new RetryException
* Introduce @NonNullByDefault
* Introduce server for RetryException, add NonNullbyDefault, fix warnings
* Rework for contribution, e.g. null checks, ...
* Fix checkstyle
* Move JsonUtils to utilities package
* Async initialize bridge handler.
* Report Device OFFLINE if Bridge is OFFLINE
* Set bridge thing status to UNKOWN in init
* Move refresh init into async init
* Cancel init task in dispose

Also-by: Leo Siepel <leosiepel@gmail.com>
Signed-off-by: Simon Spielmann <simon.spielmann@gmx.de>
This commit is contained in:
Simon Spielmann 2022-12-15 09:18:11 +01:00 committed by GitHub
parent 6e8b35c4c1
commit 04f059c455
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1736 additions and 535 deletions

View File

@ -29,6 +29,12 @@ The account Thing, more precisely the account Bridge, represents one Apple iClou
The account can be connected to multiple Apple devices which are represented as Things below the Bridge, see the example below.
You may create multiple account Things for multiple accounts.
If your Apple account has 2-factor-authentication enabled configuration requires two steps.
First start by adding the Apple ID and password to your account thing configuration.
You will receive a notification with a code on one of your Apple devices then.
Add this code to the code parameter of the thing then and wait.
The binding should be reinitialized and perform the authentication.
### Device Thing
A device is identified by the device ID provided by Apple.
@ -62,7 +68,7 @@ The following channels are available (if supported by the device):
### icloud.things
```php
Bridge icloud:account:myaccount [appleId="mail@example.com", password="secure", refreshTimeInMinutes=5]
Bridge icloud:account:myaccount [appleId="mail@example.com", password="secure", code="123456", refreshTimeInMinutes=5]
{
Thing device myiPhone8 "iPhone 8" @ "World" [deviceId="VIRG9FsrvXfE90ewVBA1H5swtwEQePdXVjHq3Si6pdJY2Cjro8QlreHYVGSUzuWV"]
}

View File

@ -0,0 +1,79 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import java.io.IOException;
import java.net.URI;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
/**
* This class gives access to the find my iPhone (FMIP) service.
*
* @author Simon Spielmann - Initial Contribution.
*/
@NonNullByDefault
public class FindMyIPhoneServiceManager {
private ICloudSession session;
private URI fmipRefreshUrl;
private URI fmipSoundUrl;
private static final String FMIP_ENDPOINT = "/fmipservice/client/web";
/**
* The constructor.
*
* @param session {@link ICloudSession} to use for API calls.
* @param serviceRoot Root URL for FMIP service.
*/
public FindMyIPhoneServiceManager(ICloudSession session, String serviceRoot) {
this.session = session;
this.fmipRefreshUrl = URI.create(serviceRoot + FMIP_ENDPOINT + "/refreshClient");
this.fmipSoundUrl = URI.create(serviceRoot + FMIP_ENDPOINT + "/playSound");
}
/**
* Receive client information as JSON.
*
* @return Information about all clients as JSON
* {@link org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudDeviceInformation}.
*
* @throws IOException if I/O error occurred
* @throws InterruptedException if this blocking request was interrupted
* @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
*
*/
public String refreshClient() throws IOException, InterruptedException, ICloudApiResponseException {
Map<String, Object> request = Map.of("clientContext",
Map.of("fmly", true, "shouldLocate", true, "selectedDevice", "All", "deviceListVersion", 1));
return session.post(this.fmipRefreshUrl.toString(), JsonUtils.toJson(request), null);
}
/**
* Play sound (find my iPhone) on given device.
*
* @param deviceId ID of the device to play sound on
* @throws IOException if I/O error occurred
* @throws InterruptedException if this blocking request was interrupted
* @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
*/
public void playSound(String deviceId) throws IOException, InterruptedException, ICloudApiResponseException {
Map<String, Object> request = Map.of("device", deviceId, "fmyl", true, "subject", "Message from openHAB.");
session.post(this.fmipSoundUrl.toString(), JsonUtils.toJson(request), null);
}
}

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
*
* Exception for errors during calls of the iCloud API.
*
* @author Simon Spielmann - Initial contribution
*/
@NonNullByDefault
public class ICloudApiResponseException extends Exception {
private static final long serialVersionUID = 1L;
private int statusCode;
/**
* The constructor.
*
* @param url URL for which the exception occurred
* @param statusCode HTTP status code which was reported
*/
public ICloudApiResponseException(String url, int statusCode) {
super(String.format("Request %s failed with %s.", url, statusCode));
this.statusCode = statusCode;
}
/**
* @return statusCode HTTP status code of failed request.
*/
public int getStatusCode() {
return this.statusCode;
}
}

View File

@ -24,9 +24,8 @@ import org.openhab.core.thing.ThingTypeUID;
* used across the whole binding.
*
* @author Patrik Gfeller - Initial contribution
* @author Patrik Gfeller
* - Class renamed to be more consistent
* - Constant FIND_MY_DEVICE_REQUEST_SUBJECT introduced
* @author Patrik Gfeller - Class renamed to be more consistent
* @author Patrik Gfeller - Constant FIND_MY_DEVICE_REQUEST_SUBJECT introduced
* @author Gaël L'hopital - Added low battery
*/
@NonNullByDefault

View File

@ -1,93 +0,0 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Duration;
import java.util.Base64;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.icloud.internal.json.request.ICloudAccountDataRequest;
import org.openhab.binding.icloud.internal.json.request.ICloudFindMyDeviceRequest;
import org.openhab.core.io.net.http.HttpRequestBuilder;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* Handles communication with the Apple server. Provides methods to
* get device information and to find a device.
*
* @author Patrik Gfeller - Initial Contribution
* @author Patrik Gfeller - SOCKET_TIMEOUT changed from 2500 to 10000
* @author Martin van Wingerden - add support for custom CA of https://fmipmobile.icloud.com
*/
@NonNullByDefault
public class ICloudConnection {
private static final String ICLOUD_URL = "https://www.icloud.com";
private static final String ICLOUD_API_BASE_URL = "https://fmipmobile.icloud.com";
private static final String ICLOUD_API_URL = ICLOUD_API_BASE_URL + "/fmipservice/device/";
private static final String ICLOUD_API_COMMAND_PING_DEVICE = "/playSound";
private static final String ICLOUD_API_COMMAND_REQUEST_DATA = "/initClient";
private static final int SOCKET_TIMEOUT = 15;
private final Gson gson = new GsonBuilder().create();
private final String iCloudDataRequest = gson.toJson(ICloudAccountDataRequest.defaultInstance());
private final String authorization;
private final String iCloudDataRequestURL;
private final String iCloudFindMyDeviceURL;
public ICloudConnection(String appleId, String password) throws URISyntaxException {
authorization = new String(Base64.getEncoder().encode((appleId + ":" + password).getBytes()), UTF_8);
iCloudDataRequestURL = new URI(ICLOUD_API_URL + appleId + ICLOUD_API_COMMAND_REQUEST_DATA).toASCIIString();
iCloudFindMyDeviceURL = new URI(ICLOUD_API_URL + appleId + ICLOUD_API_COMMAND_PING_DEVICE).toASCIIString();
}
/***
* Sends a "find my device" request.
*
* @throws IOException
*/
public void findMyDevice(String id) throws IOException {
callApi(iCloudFindMyDeviceURL, gson.toJson(new ICloudFindMyDeviceRequest(id)));
}
public String requestDeviceStatusJSON() throws IOException {
return callApi(iCloudDataRequestURL, iCloudDataRequest);
}
private String callApi(String url, String payload) throws IOException {
// @formatter:off
return HttpRequestBuilder.postTo(url)
.withTimeout(Duration.ofSeconds(SOCKET_TIMEOUT))
.withHeader("Authorization", "Basic " + authorization)
.withHeader("User-Agent", "Find iPhone/1.3 MeKit (iPad: iPhone OS/4.2.1)")
.withHeader("Origin", ICLOUD_URL)
.withHeader("charset", "utf-8")
.withHeader("Accept-language", "en-us")
.withHeader("Connection", "keep-alive")
.withHeader("X-Apple-Find-Api-Ver", "2.0")
.withHeader("X-Apple-Authscheme", "UserIdGuest")
.withHeader("X-Apple-Realm-Support", "1.0")
.withHeader("X-Client-Name", "iPad")
.withHeader("Content-Type", "application/json")
.withContent(payload)
.getContentAsString();
// @formatter:on
}
}

View File

@ -15,7 +15,7 @@ package org.openhab.binding.icloud.internal;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.icloud.internal.json.response.ICloudDeviceInformation;
import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudDeviceInformation;
/**
* Classes that implement this interface are interested in device information updates.

View File

@ -1,36 +0,0 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.json.response.ICloudAccountDataResponse;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
/**
* Extracts iCloud device information from a given JSON string
*
* @author Patrik Gfeller - Initial Contribution
*
*/
@NonNullByDefault
public class ICloudDeviceInformationParser {
private final Gson gson = new GsonBuilder().create();
public @Nullable ICloudAccountDataResponse parse(String json) throws JsonSyntaxException {
return gson.fromJson(json, ICloudAccountDataResponse.class);
}
}

View File

@ -18,6 +18,7 @@ import java.util.HashMap;
import java.util.Hashtable;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.discovery.ICloudDeviceDiscovery;
import org.openhab.binding.icloud.internal.handler.ICloudAccountBridgeHandler;
@ -25,6 +26,8 @@ import org.openhab.binding.icloud.internal.handler.ICloudDeviceHandler;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
@ -33,21 +36,34 @@ import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.framework.ServiceRegistration;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link ICloudHandlerFactory} is responsible for creating things and thing
* handlers.
* The {@link ICloudHandlerFactory} is responsible for creating things and thing handlers.
*
* @author Patrik Gfeller - Initial contribution
*/
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.icloud")
@NonNullByDefault
public class ICloudHandlerFactory extends BaseThingHandlerFactory {
private final Map<ThingUID, ServiceRegistration<?>> discoveryServiceRegistrations = new HashMap<>();
private LocaleProvider localeProvider;
private TranslationProvider i18nProvider;
private final StorageService storageService;
@Activate
public ICloudHandlerFactory(@Reference StorageService storageService, @Reference LocaleProvider localeProvider,
@Reference TranslationProvider i18nProvider) {
this.storageService = storageService;
this.localeProvider = localeProvider;
this.i18nProvider = i18nProvider;
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
@ -58,7 +74,9 @@ public class ICloudHandlerFactory extends BaseThingHandlerFactory {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(THING_TYPE_ICLOUD)) {
ICloudAccountBridgeHandler bridgeHandler = new ICloudAccountBridgeHandler((Bridge) thing);
Storage<String> storage = this.storageService.getStorage(thing.getUID().toString(),
String.class.getClassLoader());
ICloudAccountBridgeHandler bridgeHandler = new ICloudAccountBridgeHandler((Bridge) thing, storage);
registerDeviceDiscoveryService(bridgeHandler);
return bridgeHandler;
}
@ -77,42 +95,24 @@ public class ICloudHandlerFactory extends BaseThingHandlerFactory {
}
private synchronized void registerDeviceDiscoveryService(ICloudAccountBridgeHandler bridgeHandler) {
ICloudDeviceDiscovery discoveryService = new ICloudDeviceDiscovery(bridgeHandler, bundleContext.getBundle(),
i18nProvider, localeProvider);
ICloudDeviceDiscovery discoveryService = new ICloudDeviceDiscovery(bridgeHandler,
this.bundleContext.getBundle(), this.i18nProvider, this.localeProvider);
discoveryService.activate();
this.discoveryServiceRegistrations.put(bridgeHandler.getThing().getUID(),
bundleContext.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>()));
this.discoveryServiceRegistrations.put(bridgeHandler.getThing().getUID(), this.bundleContext
.registerService(DiscoveryService.class.getName(), discoveryService, new Hashtable<>()));
}
private synchronized void unregisterDeviceDiscoveryService(ICloudAccountBridgeHandler bridgeHandler) {
ServiceRegistration<?> serviceRegistration = this.discoveryServiceRegistrations
.get(bridgeHandler.getThing().getUID());
if (serviceRegistration != null) {
ICloudDeviceDiscovery discoveryService = (ICloudDeviceDiscovery) bundleContext
ICloudDeviceDiscovery discoveryService = (ICloudDeviceDiscovery) this.bundleContext
.getService(serviceRegistration.getReference());
if (discoveryService != null) {
discoveryService.deactivate();
}
serviceRegistration.unregister();
discoveryServiceRegistrations.remove(bridgeHandler.getThing().getUID());
this.discoveryServiceRegistrations.remove(bridgeHandler.getThing().getUID());
}
}
@Reference
protected void setLocaleProvider(LocaleProvider localeProvider) {
this.localeProvider = localeProvider;
}
protected void unsetLocaleProvider(LocaleProvider localeProvider) {
this.localeProvider = null;
}
@Reference
public void setTranslationProvider(TranslationProvider i18nProvider) {
this.i18nProvider = i18nProvider;
}
public void unsetTranslationProvider(TranslationProvider i18nProvider) {
this.i18nProvider = null;
}
}

View File

@ -0,0 +1,329 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
import org.openhab.binding.icloud.internal.utilities.ListUtil;
import org.openhab.binding.icloud.internal.utilities.Pair;
import org.openhab.core.storage.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* Class to access Apple iCloud API.
*
* The implementation of this class is inspired by https://github.com/picklepete/pyicloud.
*
* @author Simon Spielmann - Initial contribution
*/
@NonNullByDefault
public class ICloudService {
/**
*
*/
private static final String ICLOUD_CLIENT_ID = "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d";
private final Logger logger = LoggerFactory.getLogger(ICloudService.class);
private static final String AUTH_ENDPOINT = "https://idmsa.apple.com/appleauth/auth";
private static final String HOME_ENDPOINT = "https://www.icloud.com";
private static final String SETUP_ENDPOINT = "https://setup.icloud.com/setup/ws/1";
private String appleId;
private String password;
private String clientId;
private Map<String, Object> data = new HashMap<>();
private ICloudSession session;
/**
*
* The constructor.
*
* @param appleId Apple id (e-mail address) for authentication
* @param password Password used for authentication
* @param stateStorage Storage to save authentication state
*/
public ICloudService(String appleId, String password, Storage<String> stateStorage) {
this.appleId = appleId;
this.password = password;
this.clientId = "auth-" + UUID.randomUUID().toString().toLowerCase();
this.session = new ICloudSession(stateStorage);
this.session.setDefaultHeaders(Pair.of("Origin", HOME_ENDPOINT), Pair.of("Referer", HOME_ENDPOINT + "/"));
}
/**
* Initiate authentication
*
* @param forceRefresh Force a new authentication
* @return {@code true} if authentication was successful
* @throws IOException if I/O error occurred
* @throws InterruptedException if request was interrupted
*/
public boolean authenticate(boolean forceRefresh) throws IOException, InterruptedException {
boolean loginSuccessful = false;
if (this.session.getSessionToken() != null && !forceRefresh) {
try {
this.data = validateToken();
logger.debug("Token is valid.");
loginSuccessful = true;
} catch (ICloudApiResponseException ex) {
logger.debug("Token is not valid. Attemping new login.", ex);
}
}
if (!loginSuccessful) {
logger.debug("Authenticating as {}...", this.appleId);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("accountName", this.appleId);
requestBody.put("password", this.password);
requestBody.put("rememberMe", true);
if (session.hasToken()) {
requestBody.put("trustTokens", new String[] { this.session.getTrustToken() });
} else {
requestBody.put("trustTokens", new String[0]);
}
List<Pair<String, String>> headers = getAuthHeaders();
try {
this.session.post(AUTH_ENDPOINT + "/signin?isRememberMeEnabled=true", JsonUtils.toJson(requestBody),
headers);
} catch (ICloudApiResponseException ex) {
return false;
}
}
return authenticateWithToken();
}
/**
* Try authentication with stored session token. Returns {@code true} if authentication was successful.
*
* @return {@code true} if authentication was successful
*
* @throws IOException if I/O error occurred
* @throws InterruptedException if this request was interrupted
*
*/
public boolean authenticateWithToken() throws IOException, InterruptedException {
Map<String, Object> requestBody = new HashMap<>();
String accountCountry = session.getAccountCountry();
if (accountCountry != null) {
requestBody.put("accountCountryCode", accountCountry);
}
String sessionToken = session.getSessionToken();
if (sessionToken != null) {
requestBody.put("dsWebAuthToken", sessionToken);
}
requestBody.put("extended_login", true);
if (session.hasToken()) {
String token = session.getTrustToken();
if (token != null) {
requestBody.put("trustToken", token);
}
} else {
requestBody.put("trustToken", "");
}
try {
@Nullable
Map<String, Object> localSessionData = JsonUtils
.toMap(session.post(SETUP_ENDPOINT + "/accountLogin", JsonUtils.toJson(requestBody), null));
if (localSessionData != null) {
data = localSessionData;
}
} catch (ICloudApiResponseException ex) {
logger.debug("Invalid authentication.");
return false;
}
return true;
}
/**
* @param pair
* @return
*/
private List<Pair<String, String>> getAuthHeaders() {
return new ArrayList<>(List.of(Pair.of("Accept", "*/*"), Pair.of("Content-Type", "application/json"),
Pair.of("X-Apple-OAuth-Client-Id", ICLOUD_CLIENT_ID),
Pair.of("X-Apple-OAuth-Client-Type", "firstPartyAuth"),
Pair.of("X-Apple-OAuth-Redirect-URI", HOME_ENDPOINT),
Pair.of("X-Apple-OAuth-Require-Grant-Code", "true"),
Pair.of("X-Apple-OAuth-Response-Mode", "web_message"), Pair.of("X-Apple-OAuth-Response-Type", "code"),
Pair.of("X-Apple-OAuth-State", this.clientId), Pair.of("X-Apple-Widget-Key", ICLOUD_CLIENT_ID)));
}
private Map<String, Object> validateToken() throws IOException, InterruptedException, ICloudApiResponseException {
logger.debug("Checking session token validity");
String result = session.post(SETUP_ENDPOINT + "/validate", null, null);
logger.debug("Session token is still valid");
@Nullable
Map<String, Object> localSessionData = JsonUtils.toMap(result);
if (localSessionData == null) {
throw new IOException("Unable to create data object from json response");
}
return localSessionData;
}
/**
* Checks if 2-FA authentication is required.
*
* @return {@code true} if 2-FA authentication ({@link #validate2faCode(String)}) is required.
*/
public boolean requires2fa() {
if (this.data.containsKey("dsInfo")) {
@SuppressWarnings("unchecked")
Map<String, Object> dsInfo = (@Nullable Map<String, Object>) this.data.get("dsInfo");
if (dsInfo != null && ((Double) dsInfo.getOrDefault("hsaVersion", "0")) == 2.0) {
return (this.data.containsKey("hsaChallengeRequired")
&& ((Boolean) this.data.getOrDefault("hsaChallengeRequired", Boolean.FALSE)
|| !isTrustedSession()));
}
}
return false;
}
/**
* Checks if session is trusted.
*
* @return {@code true} if session is trusted. Call {@link #trustSession()} if not.
*/
public boolean isTrustedSession() {
return (Boolean) this.data.getOrDefault("hsaTrustedBrowser", Boolean.FALSE);
}
/**
* Provides 2-FA code to establish trusted session.
*
* @param code Code given by user for 2-FA authentication.
* @return {@code true} if code was accepted
* @throws IOException if I/O error occurred
* @throws InterruptedException if this request was interrupted
* @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
*/
public boolean validate2faCode(String code) throws IOException, InterruptedException, ICloudApiResponseException {
Map<String, Object> requestBody = Map.of("securityCode", Map.of("code", code));
List<Pair<String, String>> headers = ListUtil.replaceEntries(getAuthHeaders(),
List.of(Pair.of("Accept", "application/json")));
addSessionHeaders(headers);
try {
this.session.post(AUTH_ENDPOINT + "/verify/trusteddevice/securitycode", JsonUtils.toJson(requestBody),
headers);
} catch (ICloudApiResponseException ex) {
logger.debug("Code verification failed.", ex);
return false;
}
logger.debug("Code verification successful.");
trustSession();
return true;
}
private void addSessionHeaders(List<Pair<String, String>> headers) {
String scnt = session.getScnt();
if (scnt != null && !scnt.isEmpty()) {
headers.add(Pair.of("scnt", scnt));
}
String sessionId = session.getSessionId();
if (sessionId != null && !sessionId.isEmpty()) {
headers.add(Pair.of("X-Apple-ID-Session-Id", sessionId));
}
}
private @Nullable String getWebserviceUrl(String wsKey) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> webservices = (@Nullable Map<String, Object>) data.get("webservices");
if (webservices == null) {
return null;
}
if (webservices.get(wsKey) instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, ?> wsMap = (@Nullable Map<String, ?>) webservices.get(wsKey);
if (wsMap == null) {
logger.error("Webservices result map has not expected format.");
return null;
}
return (String) wsMap.get("url");
} else {
logger.error("Webservices result map has not expected format.");
return null;
}
} catch (ClassCastException e) {
logger.error("ClassCastException, map has not expected format.", e);
return null;
}
}
/**
* Establish trust for current session.
*
* @return {@code true} if successful.
*
* @throws IOException if I/O error occurred
* @throws InterruptedException if this request was interrupted
* @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
*
*/
public boolean trustSession() throws IOException, InterruptedException, ICloudApiResponseException {
List<Pair<String, String>> headers = getAuthHeaders();
addSessionHeaders(headers);
this.session.get(AUTH_ENDPOINT + "/2sv/trust", headers);
return authenticateWithToken();
}
/**
* Get access to find my iPhone service.
*
* @return Instance of {@link FindMyIPhoneServiceManager} for this session.
* @throws IOException if I/O error occurred
* @throws InterruptedException if this request was interrupted
*/
public FindMyIPhoneServiceManager getDevices() throws IOException, InterruptedException {
String webserviceUrl = getWebserviceUrl("findme");
if (webserviceUrl != null) {
return new FindMyIPhoneServiceManager(this.session, webserviceUrl);
} else {
throw new IllegalStateException("Webservice URLs not set. Need to authenticate first.");
}
}
}

View File

@ -0,0 +1,239 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import java.io.IOException;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.net.http.HttpClient.Version;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpRequest.Builder;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.utilities.CustomCookieStore;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
import org.openhab.binding.icloud.internal.utilities.ListUtil;
import org.openhab.binding.icloud.internal.utilities.Pair;
import org.openhab.core.storage.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
*
* Class to handle iCloud API session information for accessing the API.
*
* The implementation of this class is inspired by https://github.com/picklepete/pyicloud.
*
* @author Simon Spielmann - Initial contribution
*/
@NonNullByDefault
public class ICloudSession {
private final Logger logger = LoggerFactory.getLogger(ICloudSession.class);
private final HttpClient client;
private List<Pair<String, String>> headers = new ArrayList<>();
private ICloudSessionData data = new ICloudSessionData();
private Storage<String> stateStorage;
private static final String SESSION_DATA_KEY = "SESSION_DATA";
/**
* The constructor.
*
* @param stateStorage Storage to persist session state.
*/
public ICloudSession(Storage<String> stateStorage) {
String storedData = stateStorage.get(SESSION_DATA_KEY);
if (storedData != null) {
ICloudSessionData localSessionData = JsonUtils.fromJson(storedData, ICloudSessionData.class);
if (localSessionData != null) {
data = localSessionData;
}
}
this.stateStorage = stateStorage;
client = HttpClient.newBuilder().version(Version.HTTP_1_1).followRedirects(Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(20))
.cookieHandler(new CookieManager(new CustomCookieStore(), CookiePolicy.ACCEPT_ALL)).build();
}
/**
* Invoke an HTTP POST request to the given url and body.
*
* @param url URL to call.
* @param body Body for the request
* @param overrideHeaders  If not null the given headers are used instead of the standard headers set via
* {@link #setDefaultHeaders(Pair...)} (optional)
* @return Result body as {@link String}.
* @throws IOException if I/O error occurred
* @throws InterruptedException if this blocking request was interrupted
* @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
*/
public String post(String url, @Nullable String body, @Nullable List<Pair<String, String>> overrideHeaders)
throws IOException, InterruptedException, ICloudApiResponseException {
return request("POST", url, body, overrideHeaders);
}
/**
* Invoke an HTTP GET request to the given url.
*
* @param url URL to call.
* @param overrideHeaders  If not null the given headers are used to replace corresponding entries of the standard
* headers set via
* {@link #setDefaultHeaders(Pair...)}
* @return Result body as {@link String}.
* @throws IOException if I/O error occurred
* @throws InterruptedException if this blocking request was interrupted
* @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
*/
public String get(String url, List<Pair<String, String>> overrideHeaders)
throws IOException, InterruptedException, ICloudApiResponseException {
return request("GET", url, null, overrideHeaders);
}
private String request(String method, String url, @Nullable String body,
@Nullable List<Pair<String, String>> overrideHeaders)
throws IOException, InterruptedException, ICloudApiResponseException {
logger.debug("iCloud request {} {}.", method, url);
Builder builder = HttpRequest.newBuilder().uri(URI.create(url));
List<Pair<String, String>> requestHeaders = ListUtil.replaceEntries(this.headers, overrideHeaders);
for (Pair<String, String> header : requestHeaders) {
builder.header(header.getKey(), header.getValue());
}
if (body != null) {
builder.method(method, BodyPublishers.ofString(body));
}
HttpRequest request = builder.build();
logger.trace("Calling {}\nHeaders -----\n{}\nBody -----\n{}\n------\n", url, request.headers(), body);
HttpResponse<?> response = this.client.send(request, BodyHandlers.ofString());
Object responseBody = response.body();
String responseBodyAsString = responseBody != null ? responseBody.toString() : "";
logger.trace("Result {} {}\nHeaders -----\n{}\nBody -----\n{}\n------\n", url, response.statusCode(),
response.headers(), responseBodyAsString);
if (response.statusCode() >= 300) {
throw new ICloudApiResponseException(url, response.statusCode());
}
// Store headers to reuse authentication
this.data.accountCountry = response.headers().firstValue("X-Apple-ID-Account-Country")
.orElse(getAccountCountry());
this.data.sessionId = response.headers().firstValue("X-Apple-ID-Session-Id").orElse(getSessionId());
this.data.sessionToken = response.headers().firstValue("X-Apple-Session-Token").orElse(getSessionToken());
this.data.trustToken = response.headers().firstValue("X-Apple-TwoSV-Trust-Token").orElse(getTrustToken());
this.data.scnt = response.headers().firstValue("scnt").orElse(getScnt());
this.stateStorage.put(SESSION_DATA_KEY, JsonUtils.toJson(this.data));
return responseBodyAsString;
}
/**
* Sets default HTTP headers, for HTTP requests.
*
* @param headers HTTP headers to use for requests
*/
@SafeVarargs
public final void setDefaultHeaders(Pair<String, String>... headers) {
this.headers = Arrays.asList(headers);
}
/**
* @return scnt
*/
public @Nullable String getScnt() {
return data.scnt;
}
/**
* @return sessionId
*/
public @Nullable String getSessionId() {
return data.sessionId;
}
/**
* @return sessionToken
*/
public @Nullable String getSessionToken() {
return data.sessionToken;
}
/**
* @return trustToken
*/
public @Nullable String getTrustToken() {
return data.trustToken;
}
/**
* @return {@code true} if session token is not empty.
*/
public boolean hasToken() {
String sessionToken = data.sessionToken;
return sessionToken != null && !sessionToken.isEmpty();
}
/**
* @return accountCountry
*/
public @Nullable String getAccountCountry() {
return data.accountCountry;
}
/**
*
* Internal class to encapsulate data required for iCloud authentication.
*
* @author Simon Spielmann Initial Contribution
*/
private class ICloudSessionData {
@Nullable
String scnt;
@Nullable
String sessionId;
@Nullable
String sessionToken;
@Nullable
String trustToken;
@Nullable
String accountCountry;
}
}

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* This exception is thrown when a retry finally fails.
*
* @author Simon Spielmann - Initial contribution
*/
@NonNullByDefault
public class RetryException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* The constructor.
*
* @param originalException Exception which was thrown for the last unsuccessful retry.
*/
public RetryException(@Nullable Throwable originalException) {
super("Retry finally failed.", originalException);
}
}

View File

@ -26,4 +26,5 @@ public class ICloudAccountThingConfiguration {
public @Nullable String appleId;
public @Nullable String password;
public int refreshTimeInMinutes = 10;
public @Nullable String code;
}

View File

@ -19,7 +19,7 @@ import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.icloud.internal.ICloudDeviceInformationListener;
import org.openhab.binding.icloud.internal.handler.ICloudAccountBridgeHandler;
import org.openhab.binding.icloud.internal.json.response.ICloudDeviceInformation;
import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudDeviceInformation;
import org.openhab.binding.icloud.internal.utilities.ICloudTextTranslator;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResult;

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Enum to mark state during iCloud authentication.
*
* @author Simon Spielmann - Initial contribution
*
*/
@NonNullByDefault
public enum AuthState {
/**
* Authentication was not tried yet.
*/
INITIAL,
/**
* Entered credentials (apple id / password) are invalid.
*/
USER_PW_INVALID,
/**
* Waiting for user to provide 2-FA code in thing configuration.
*/
WAIT_FOR_CODE,
/**
* Sucessfully authenticated.
*/
AUTHENTICATED
}

View File

@ -15,21 +15,26 @@ package org.openhab.binding.icloud.internal.handler;
import static java.util.concurrent.TimeUnit.*;
import java.io.IOException;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.ICloudConnection;
import org.openhab.binding.icloud.internal.ICloudApiResponseException;
import org.openhab.binding.icloud.internal.ICloudDeviceInformationListener;
import org.openhab.binding.icloud.internal.ICloudDeviceInformationParser;
import org.openhab.binding.icloud.internal.ICloudService;
import org.openhab.binding.icloud.internal.RetryException;
import org.openhab.binding.icloud.internal.configuration.ICloudAccountThingConfiguration;
import org.openhab.binding.icloud.internal.json.response.ICloudAccountDataResponse;
import org.openhab.binding.icloud.internal.json.response.ICloudDeviceInformation;
import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudAccountDataResponse;
import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudDeviceInformation;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.storage.Storage;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
@ -37,19 +42,18 @@ import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.osgi.framework.ServiceRegistration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonSyntaxException;
/**
* Retrieves the data for a given account from iCloud and passes the
* information to {@link org.openhab.binding.icloud.internal.discovery.ICloudDeviceDiscovery} and to the
* {@link ICloudDeviceHandler}s.
* Retrieves the data for a given account from iCloud and passes the information to
* {@link org.openhab.binding.icloud.internal.discovery.ICloudDeviceDiscovery} and to the {@link ICloudDeviceHandler}s.
*
* @author Patrik Gfeller - Initial contribution
* @author Hans-Jörg Merk - Extended support with initial Contribution
* @author Simon Spielmann - Rework for new iCloud API
*/
@NonNullByDefault
public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
@ -58,23 +62,36 @@ public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
private static final int CACHE_EXPIRY = (int) SECONDS.toMillis(10);
private final ICloudDeviceInformationParser deviceInformationParser = new ICloudDeviceInformationParser();
private @Nullable ICloudConnection connection;
private @Nullable ICloudService iCloudService;
private @Nullable ExpiringCache<String> iCloudDeviceInformationCache;
@Nullable
ServiceRegistration<?> service;
private AuthState authState = AuthState.INITIAL;
private final Object synchronizeRefresh = new Object();
private List<ICloudDeviceInformationListener> deviceInformationListeners = Collections
.synchronizedList(new ArrayList<>());
private Set<ICloudDeviceInformationListener> deviceInformationListeners = Collections
.synchronizedSet(new HashSet<>());
@Nullable
ScheduledFuture<?> refreshJob;
public ICloudAccountBridgeHandler(Bridge bridge) {
@Nullable
ScheduledFuture<?> initTask;
private Storage<String> storage;
private static final String AUTH_CODE_KEY = "AUTH_CODE";
/**
* The constructor.
*
* @param bridge The bridge to set
* @param storage The storage service to set.
*/
public ICloudAccountBridgeHandler(Bridge bridge, Storage<String> storage) {
super(bridge);
this.storage = storage;
}
@Override
@ -86,21 +103,161 @@ public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
}
}
@SuppressWarnings("null")
@Override
public void initialize() {
logger.debug("iCloud bridge handler initializing ...");
iCloudDeviceInformationCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
if (authState != AuthState.WAIT_FOR_CODE) {
authState = AuthState.INITIAL;
}
this.iCloudDeviceInformationCache = new ExpiringCache<>(CACHE_EXPIRY, () -> {
return callApiWithRetryAndExceptionHandling(() -> {
// callApiWithRetryAndExceptionHanlding ensures that iCloudService is not null when the following is
// called. Cannot use method local iCloudService instance here, because instance may be replaced with a
// new
// one during retry.
return iCloudService.getDevices().refreshClient();
});
});
updateStatus(ThingStatus.UNKNOWN);
// Init has to be done async becaue it requires sync network calls, which are not allowed in init.
Callable<?> asyncInit = () -> {
callApiWithRetryAndExceptionHandling(() -> {
logger.debug("Dummy call for initial authentication.");
return null;
});
if (authState == AuthState.AUTHENTICATED) {
ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
this.refreshJob = this.scheduler.scheduleWithFixedDelay(this::refreshData, 0,
config.refreshTimeInMinutes, MINUTES);
} else {
cancelRefresh();
}
return null;
};
initTask = this.scheduler.schedule(asyncInit, 0, TimeUnit.SECONDS);
logger.debug("iCloud bridge handler initialized.");
}
private <@Nullable T> T callApiWithRetryAndExceptionHandling(Callable<T> wrapped) {
int retryCount = 1;
boolean success = false;
Throwable lastException = null;
synchronized (synchronizeRefresh) {
if (this.iCloudService == null) {
ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
final String localAppleId = config.appleId;
final String localPassword = config.password;
if (localAppleId != null && localPassword != null) {
this.iCloudService = new ICloudService(localAppleId, localPassword, this.storage);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Apple ID or password is not set!");
return null;
}
}
if (authState == AuthState.INITIAL) {
success = checkLogin();
} else if (authState == AuthState.WAIT_FOR_CODE) {
try {
return connection.requestDeviceStatusJSON();
success = handle2FAAuthentication();
} catch (IOException | InterruptedException | ICloudApiResponseException ex) {
logger.debug("Error while validating 2-FA code.", ex);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Error while validating 2-FA code.");
return null;
}
}
if (authState != AuthState.AUTHENTICATED && !success) {
return null;
}
do {
try {
if (authState == AuthState.AUTHENTICATED) {
return wrapped.call();
} else {
checkLogin();
}
} catch (ICloudApiResponseException e) {
logger.debug("ICloudApiResponseException with status code {}", e.getStatusCode());
lastException = e;
if (e.getStatusCode() == 450) {
checkLogin();
}
} catch (IllegalStateException e) {
logger.debug("Need to authenticate first.", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Wait for login");
return null;
} catch (IOException e) {
logger.warn("Unable to refresh device data", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
return null;
} catch (Exception e) {
logger.debug("Unexpected exception occured", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
return null;
}
});
startHandler();
logger.debug("iCloud bridge initialized.");
retryCount++;
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.interrupted();
}
} while (!success && retryCount < 3);
throw new RetryException(lastException);
}
}
private boolean handle2FAAuthentication() throws IOException, InterruptedException, ICloudApiResponseException {
logger.debug("Starting iCloud 2-FA authentication AuthState={}, Thing={})...", authState,
getThing().getUID().getAsString());
final ICloudService localICloudService = this.iCloudService;
if (authState != AuthState.WAIT_FOR_CODE || localICloudService == null) {
throw new IllegalStateException("2-FA authentication not initialized.");
}
ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
String lastTriedCode = storage.get(AUTH_CODE_KEY);
String code = config.code;
boolean success = false;
if (code == null || code.isBlank() || code.equals(lastTriedCode)) {
// Still waiting for user to update config.
logger.warn("ICloud authentication requires 2-FA code. Please provide code configuration for thing '{}'.",
getThing().getUID().getAsString());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Please provide 2-FA code in thing configuration.");
return false;
} else {
// 2-FA-Code was requested in previous call of this method.
// User has provided code in config.
logger.debug("Code is given in thing configuration '{}'. Trying to validate code...",
getThing().getUID().getAsString());
storage.put(AUTH_CODE_KEY, lastTriedCode);
success = localICloudService.validate2faCode(code);
if (!success) {
authState = AuthState.INITIAL;
logger.warn("ICloud token invalid.");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid 2-FA-code.");
return false;
}
org.openhab.core.config.core.Configuration config2 = editConfiguration();
config2.put("code", "");
updateConfiguration(config2);
logger.debug("Code is valid.");
}
authState = AuthState.AUTHENTICATED;
updateStatus(ThingStatus.ONLINE);
logger.debug("iCloud bridge handler '{}' authenticated with 2-FA code.", getThing().getUID().getAsString());
return success;
}
@Override
@ -110,55 +267,126 @@ public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
@Override
public void dispose() {
if (refreshJob != null) {
refreshJob.cancel(true);
cancelRefresh();
final ScheduledFuture<?> localInitTask = this.initTask;
if (localInitTask != null) {
localInitTask.cancel(true);
this.initTask = null;
}
super.dispose();
}
public void findMyDevice(String deviceId) throws IOException {
if (connection == null) {
logger.debug("Can't send Find My Device request, because connection is null!");
return;
private void cancelRefresh() {
final ScheduledFuture<?> localrefreshJob = this.refreshJob;
if (localrefreshJob != null) {
localrefreshJob.cancel(true);
this.refreshJob = null;
}
connection.findMyDevice(deviceId);
}
@SuppressWarnings("null")
public void findMyDevice(String deviceId) throws IOException, InterruptedException {
callApiWithRetryAndExceptionHandling(() -> {
// callApiWithRetryAndExceptionHanlding ensures that iCloudService is not null when the following is
// called. Cannot use method local iCloudService instance here, because instance may be replaced with a new
// one during retry.
iCloudService.getDevices().playSound(deviceId);
return null;
});
}
public void registerListener(ICloudDeviceInformationListener listener) {
deviceInformationListeners.add(listener);
this.deviceInformationListeners.add(listener);
}
public void unregisterListener(ICloudDeviceInformationListener listener) {
deviceInformationListeners.remove(listener);
this.deviceInformationListeners.remove(listener);
}
/**
* Checks login to iCloud account. The flow is a bit complicated due to 2-FA authentication.
* The normal flow would be:
*
*
* <pre>
ICloudService service = new ICloudService(...);
service.authenticate(false);
if (service.requires2fa()) {
String code = ... // Request code from user!
System.out.println(service.validate2faCode(code));
if (!service.isTrustedSession()) {
service.trustSession();
}
if (!service.isTrustedSession()) {
System.err.println("Trust failed!!!");
}
* </pre>
*
* The call to {@link ICloudService#authenticate(boolean)} request a token from the user.
* This should be done only once. Afterwards the user has to update the configuration.
* In openhab this method here is called for several reason (e.g. config change). So we track if we already
* requested a code {@link #validate2faCode}.
*/
private boolean checkLogin() {
logger.debug("Starting iCloud authentication (AuthState={}, Thing={})...", authState,
getThing().getUID().getAsString());
final ICloudService localICloudService = this.iCloudService;
if (authState == AuthState.WAIT_FOR_CODE || localICloudService == null) {
throw new IllegalStateException("2-FA authentication not completed.");
}
private void startHandler() {
try {
logger.debug("iCloud bridge starting handler ...");
ICloudAccountThingConfiguration config = getConfigAs(ICloudAccountThingConfiguration.class);
final String localAppleId = config.appleId;
final String localPassword = config.password;
if (localAppleId != null && localPassword != null) {
connection = new ICloudConnection(localAppleId, localPassword);
} else {
// No code requested yet or session is trusted (hopefully).
boolean success = localICloudService.authenticate(false);
if (!success) {
authState = AuthState.USER_PW_INVALID;
logger.warn("iCloud authentication failed. Invalid credentials.");
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid credentials.");
this.iCloudService = null;
return false;
}
if (localICloudService.requires2fa()) {
// New code was requested. Wait for the user to update config.
logger.warn(
"iCloud authentication requires 2-FA code. Please provide code configuration for thing '{}'.",
getThing().getUID().getAsString());
authState = AuthState.WAIT_FOR_CODE;
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Apple ID/Password is not set!");
"Please provide 2-FA code in thing configuration.");
return false;
}
if (!localICloudService.isTrustedSession()) {
logger.debug("Trying to establish session trust.");
success = localICloudService.trustSession();
if (!success) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Session trust failed.");
return false;
}
}
authState = AuthState.AUTHENTICATED;
updateStatus(ThingStatus.ONLINE);
logger.debug("iCloud bridge handler authenticated.");
return true;
} catch (Exception e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
return false;
}
}
/**
* Refresh iCloud device data.
*/
public void refreshData() {
logger.debug("iCloud bridge refreshing data ...");
synchronized (this.synchronizeRefresh) {
ExpiringCache<String> localCache = this.iCloudDeviceInformationCache;
if (localCache == null) {
return;
}
refreshJob = scheduler.scheduleWithFixedDelay(this::refreshData, 0, config.refreshTimeInMinutes, MINUTES);
logger.debug("iCloud bridge handler started.");
} catch (URISyntaxException e) {
logger.debug("Something went wrong while constructing the connection object", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
}
}
public void refreshData() {
synchronized (synchronizeRefresh) {
logger.debug("iCloud bridge refreshing data ...");
String json = iCloudDeviceInformationCache.getValue();
String json = localCache.getValue();
logger.trace("json: {}", json);
if (json == null) {
@ -166,7 +394,7 @@ public class ICloudAccountBridgeHandler extends BaseBridgeHandler {
}
try {
ICloudAccountDataResponse iCloudData = deviceInformationParser.parse(json);
ICloudAccountDataResponse iCloudData = JsonUtils.fromJson(json, ICloudAccountDataResponse.class);
if (iCloudData == null) {
return;
}

View File

@ -27,7 +27,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.icloud.internal.ICloudDeviceInformationListener;
import org.openhab.binding.icloud.internal.configuration.ICloudDeviceThingConfiguration;
import org.openhab.binding.icloud.internal.json.response.ICloudDeviceInformation;
import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudDeviceInformation;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.OnOffType;
@ -36,6 +36,8 @@ import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.types.Command;
@ -51,13 +53,14 @@ import org.slf4j.LoggerFactory;
* @author Patrik Gfeller - Initial contribution
* @author Hans-Jörg Merk - Helped with testing and feedback
* @author Gaël L'hopital - Added low battery
* @author Simon Spielmann - Rework for new iCloud API
*
*/
@NonNullByDefault
public class ICloudDeviceHandler extends BaseThingHandler implements ICloudDeviceInformationListener {
private final Logger logger = LoggerFactory.getLogger(ICloudDeviceHandler.class);
private @Nullable String deviceId;
private @Nullable ICloudAccountBridgeHandler icloudAccount;
public ICloudDeviceHandler(Thing thing) {
super(thing);
@ -67,7 +70,7 @@ public class ICloudDeviceHandler extends BaseThingHandler implements ICloudDevic
public void deviceInformationUpdate(List<ICloudDeviceInformation> deviceInformationList) {
ICloudDeviceInformation deviceInformationRecord = getDeviceInformationRecord(deviceInformationList);
if (deviceInformationRecord != null) {
if (deviceInformationRecord.getDeviceStatus() == 200 || deviceInformationRecord.getDeviceStatus() == 203) {
if (deviceInformationRecord.getDeviceStatus() == 200) {
updateStatus(ONLINE);
} else {
updateStatus(OFFLINE, COMMUNICATION_ERROR, "Reported offline by iCloud webservice");
@ -91,35 +94,42 @@ public class ICloudDeviceHandler extends BaseThingHandler implements ICloudDevic
@Override
public void initialize() {
Bridge bridge = getBridge();
Object bridgeStatus = (bridge == null) ? null : bridge.getStatus();
logger.debug("initializeThing thing [{}]; bridge status: [{}]", getThing().getUID(), bridgeStatus);
ICloudDeviceThingConfiguration configuration = getConfigAs(ICloudDeviceThingConfiguration.class);
this.deviceId = configuration.deviceId;
ICloudAccountBridgeHandler handler = getIcloudAccount();
if (handler != null) {
refreshData();
} else {
updateStatus(OFFLINE, BRIDGE_UNINITIALIZED, "Bridge not found");
}
}
private void refreshData() {
ICloudAccountBridgeHandler bridge = getIcloudAccount();
Bridge bridge = getBridge();
if (bridge != null) {
bridge.refreshData();
ICloudAccountBridgeHandler handler = (ICloudAccountBridgeHandler) bridge.getHandler();
if (handler != null) {
handler.registerListener(this);
if (bridge.getStatus() == ThingStatus.ONLINE) {
handler.refreshData();
updateStatus(ThingStatus.ONLINE);
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED,
"Bridge handler is not configured");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge is not configured");
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.trace("Command '{}' received for channel '{}'", command, channelUID);
this.logger.trace("Command '{}' received for channel '{}'", command, channelUID);
ICloudAccountBridgeHandler bridge = getIcloudAccount();
Bridge bridge = getBridge();
if (bridge == null) {
logger.debug("No bridge found, ignoring command");
this.logger.debug("No bridge found, ignoring command");
return;
}
ICloudAccountBridgeHandler bridgeHandler = (ICloudAccountBridgeHandler) bridge.getHandler();
if (bridgeHandler == null) {
this.logger.debug("No bridge handler found, ignoring command");
return;
}
@ -129,27 +139,30 @@ public class ICloudDeviceHandler extends BaseThingHandler implements ICloudDevic
try {
final String deviceId = this.deviceId;
if (deviceId == null) {
logger.debug("Can't send Find My Device request, because deviceId is null!");
this.logger.debug("Can't send Find My Device request, because deviceId is null!");
return;
}
bridge.findMyDevice(deviceId);
} catch (IOException e) {
logger.warn("Unable to execute find my device request", e);
bridgeHandler.findMyDevice(deviceId);
} catch (IOException | InterruptedException e) {
this.logger.warn("Unable to execute find my device request", e);
}
updateState(FIND_MY_PHONE, OnOffType.OFF);
}
}
if (command instanceof RefreshType) {
bridge.refreshData();
bridgeHandler.refreshData();
}
}
@Override
public void dispose() {
ICloudAccountBridgeHandler bridge = getIcloudAccount();
Bridge bridge = getBridge();
if (bridge != null) {
bridge.unregisterListener(this);
ThingHandler bridgeHandler = bridge.getHandler();
if (bridgeHandler instanceof ICloudAccountBridgeHandler) {
((ICloudAccountBridgeHandler) bridgeHandler).unregisterListener(this);
}
}
super.dispose();
}
@ -169,7 +182,7 @@ public class ICloudDeviceHandler extends BaseThingHandler implements ICloudDevic
private @Nullable ICloudDeviceInformation getDeviceInformationRecord(
List<ICloudDeviceInformation> deviceInformationList) {
logger.debug("Device: [{}]", deviceId);
this.logger.debug("Device: [{}]", this.deviceId);
for (ICloudDeviceInformation deviceInformationRecord : deviceInformationList) {
String currentId = deviceInformationRecord.getId();
@ -196,21 +209,4 @@ public class ICloudDeviceHandler extends BaseThingHandler implements ICloudDevic
return dateTime;
}
private @Nullable ICloudAccountBridgeHandler getIcloudAccount() {
if (icloudAccount == null) {
Bridge bridge = getBridge();
if (bridge == null) {
return null;
}
ThingHandler handler = bridge.getHandler();
if (handler instanceof ICloudAccountBridgeHandler) {
icloudAccount = (ICloudAccountBridgeHandler) handler;
icloudAccount.registerListener(this);
} else {
return null;
}
}
return icloudAccount;
}
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.response;
package org.openhab.binding.icloud.internal.handler.dto.json.response;
import java.util.List;

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.response;
package org.openhab.binding.icloud.internal.handler.dto.json.response;
/**
* Serializable class to parse json response received from the Apple server.

View File

@ -0,0 +1,180 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.handler.dto.json.response;
import com.google.gson.annotations.SerializedName;
/**
* Serializable class to parse json response received from the Apple server.
*
* @author Patrik Gfeller - Initial Contribution
*
*/
public class ICloudDeviceFeatures {
@SerializedName("CLK")
private boolean clk;
@SerializedName("CLT")
private boolean clt;
@SerializedName("CWP")
private boolean cwp;
@SerializedName("KEY")
private boolean key;
@SerializedName("KPD")
private boolean kpd;
@SerializedName("LCK")
private boolean lck;
@SerializedName("LKL")
private boolean lkl;
@SerializedName("LKM")
private boolean lkm;
@SerializedName("LLC")
private boolean llc;
@SerializedName("LMG")
private boolean lmg;
@SerializedName("LOC")
private boolean loc;
@SerializedName("LST")
private boolean lst;
@SerializedName("MCS")
private boolean mcs;
@SerializedName("MSG")
private boolean msg;
@SerializedName("PIN")
private boolean pin;
@SerializedName("REM")
private boolean rem;
@SerializedName("SND")
private boolean snd;
@SerializedName("SVP")
private boolean svp;
@SerializedName("TEU")
private boolean teu;
@SerializedName("WIP")
private boolean wip;
@SerializedName("WMG")
private boolean wmg;
@SerializedName("XRM")
private boolean xrm;
@SerializedName("CLT")
public boolean getCLK() {
return this.clk;
}
public boolean getClt() {
return this.clt;
}
public boolean getCwp() {
return this.cwp;
}
public boolean getKey() {
return this.key;
}
public boolean getKpd() {
return this.kpd;
}
public boolean getLck() {
return this.lck;
}
public boolean getLkl() {
return this.lkl;
}
public boolean getLkm() {
return this.lkm;
}
public boolean getLlc() {
return this.llc;
}
public boolean getLmg() {
return this.lmg;
}
public boolean getLoc() {
return this.loc;
}
public boolean getLst() {
return this.lst;
}
public boolean getMcs() {
return this.mcs;
}
public boolean getMsg() {
return this.msg;
}
public boolean getPin() {
return this.pin;
}
public boolean getRem() {
return this.rem;
}
public boolean getSnd() {
return this.snd;
}
public boolean getSvp() {
return this.svp;
}
public boolean getTeu() {
return this.teu;
}
public boolean getWip() {
return this.wip;
}
public boolean getWmg() {
return this.wmg;
}
public boolean getXrm() {
return this.xrm;
}
}

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.response;
package org.openhab.binding.icloud.internal.handler.dto.json.response;
import java.util.ArrayList;

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.response;
package org.openhab.binding.icloud.internal.handler.dto.json.response;
/**
* Serializable class to parse json response received from the Apple server.

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.response;
package org.openhab.binding.icloud.internal.handler.dto.json.response;
import org.eclipse.jdt.annotation.Nullable;

View File

@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.response;
package org.openhab.binding.icloud.internal.handler.dto.json.response;
/**
* Serializable class to parse json response received from the Apple server.

View File

@ -1,64 +0,0 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.request;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Serializable request for icloud device data.
*
* @author Patrik Gfeller - Initial Contribution
*
*/
@NonNullByDefault
public class ICloudAccountDataRequest {
@SuppressWarnings("unused")
private ClientContext clientContext;
private ICloudAccountDataRequest() {
this.clientContext = ClientContext.defaultInstance();
}
public static ICloudAccountDataRequest defaultInstance() {
return new ICloudAccountDataRequest();
}
public static class ClientContext {
@SuppressWarnings("unused")
private String appName = "FindMyiPhone";
@SuppressWarnings("unused")
private boolean fmly = true;
@SuppressWarnings("unused")
private String appVersion = "5.0";
@SuppressWarnings("unused")
private String buildVersion = "376";
@SuppressWarnings("unused")
private int clientTimestamp = 0;
@SuppressWarnings("unused")
private String deviceUDID = "";
@SuppressWarnings("unused")
private int inactiveTime = 1;
@SuppressWarnings("unused")
private String osVersion = "14.0";
@SuppressWarnings("unused")
private String productType = "iPhone14,2";
private ClientContext() {
// empty to hide constructor
}
public static ClientContext defaultInstance() {
return new ClientContext();
}
}
}

View File

@ -1,37 +0,0 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.request;
import static org.openhab.binding.icloud.internal.ICloudBindingConstants.FIND_MY_DEVICE_REQUEST_SUBJECT;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.annotations.SerializedName;
/**
* Serializable class to create a "Find My Device" json request string.
*
* @author Patrik Gfeller - Initial Contribution
*/
@NonNullByDefault
public class ICloudFindMyDeviceRequest {
@SerializedName("device")
@Nullable
String deviceId;
final String subject = FIND_MY_DEVICE_REQUEST_SUBJECT;
public ICloudFindMyDeviceRequest(String id) {
deviceId = id;
}
}

View File

@ -1,153 +0,0 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.json.response;
/**
* Serializable class to parse json response received from the Apple server.
*
* @author Patrik Gfeller - Initial Contribution
*
*/
public class ICloudDeviceFeatures {
private boolean CLK;
private boolean CLT;
private boolean CWP;
private boolean KEY;
private boolean KPD;
private boolean LCK;
private boolean LKL;
private boolean LKM;
private boolean LLC;
private boolean LMG;
private boolean LOC;
private boolean LST;
private boolean MCS;
private boolean MSG;
private boolean PIN;
private boolean REM;
private boolean SND;
private boolean SVP;
private boolean TEU;
private boolean WIP;
private boolean WMG;
private boolean XRM;
public boolean getCLK() {
return this.CLK;
}
public boolean getCLT() {
return this.CLT;
}
public boolean getCWP() {
return this.CWP;
}
public boolean getKEY() {
return this.KEY;
}
public boolean getKPD() {
return this.KPD;
}
public boolean getLCK() {
return this.LCK;
}
public boolean getLKL() {
return this.LKL;
}
public boolean getLKM() {
return this.LKM;
}
public boolean getLLC() {
return this.LLC;
}
public boolean getLMG() {
return this.LMG;
}
public boolean getLOC() {
return this.LOC;
}
public boolean getLST() {
return this.LST;
}
public boolean getMCS() {
return this.MCS;
}
public boolean getMSG() {
return this.MSG;
}
public boolean getPIN() {
return this.PIN;
}
public boolean getREM() {
return this.REM;
}
public boolean getSND() {
return this.SND;
}
public boolean getSVP() {
return this.SVP;
}
public boolean getTEU() {
return this.TEU;
}
public boolean getWIP() {
return this.WIP;
}
public boolean getWMG() {
return this.WMG;
}
public boolean getXRM() {
return this.XRM;
}
}

View File

@ -0,0 +1,90 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.utilities;
import java.net.CookieManager;
import java.net.CookieStore;
import java.net.HttpCookie;
import java.net.URI;
import java.util.List;
import org.eclipse.jdt.annotation.Nullable;
/**
* This class implements a customized {@link CookieStore}. Its purpose is to add hyphens at the beginning and end of
* each cookie value which is required by Apple iCloud API.
*
* @author Simon Spielmann - Initial contribution
*/
public class CustomCookieStore implements CookieStore {
private CookieStore cookieStore;
/**
* The constructor.
*
*/
public CustomCookieStore() {
this.cookieStore = new CookieManager().getCookieStore();
}
@Override
public void add(@Nullable URI uri, @Nullable HttpCookie cookie) {
this.cookieStore.add(uri, cookie);
}
@Override
public @Nullable List<HttpCookie> get(@Nullable URI uri) {
List<HttpCookie> result = this.cookieStore.get(uri);
filterCookies(result);
return result;
}
@Override
public @Nullable List<HttpCookie> getCookies() {
List<HttpCookie> result = this.cookieStore.getCookies();
filterCookies(result);
return result;
}
@Override
public @Nullable List<URI> getURIs() {
return this.cookieStore.getURIs();
}
@Override
public boolean remove(@Nullable URI uri, @Nullable HttpCookie cookie) {
return this.cookieStore.remove(uri, cookie);
}
@Override
public boolean removeAll() {
return this.cookieStore.removeAll();
}
/**
* Add quotes add beginning and end of all cookie values
*
* @param cookieList Current cookies. This list is modified in-place.
*/
private void filterCookies(List<HttpCookie> cookieList) {
for (HttpCookie cookie : cookieList) {
if (!cookie.getValue().startsWith("\"")) {
cookie.setValue("\"" + cookie.getValue());
}
if (!cookie.getValue().endsWith("\"")) {
cookie.setValue(cookie.getValue() + "\"");
}
}
}
}

View File

@ -14,6 +14,7 @@ package org.openhab.binding.icloud.internal.utilities;
import java.util.Locale;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.TranslationProvider;
@ -24,6 +25,7 @@ import org.osgi.framework.Bundle;
*
* @author Patrik Gfeller - Initial contribution
*/
@NonNullByDefault
public class ICloudTextTranslator {
private final Bundle bundle;
@ -37,11 +39,13 @@ public class ICloudTextTranslator {
}
public String getText(String key, Object... arguments) {
Locale locale = localeProvider != null ? localeProvider.getLocale() : Locale.ENGLISH;
return i18nProvider != null ? i18nProvider.getText(bundle, key, getDefaultText(key), locale, arguments) : key;
Locale locale = localeProvider.getLocale();
String retText = i18nProvider.getText(bundle, key, getDefaultText(key), locale, arguments);
return retText != null ? retText : key;
}
public String getDefaultText(@Nullable String key) {
return i18nProvider.getText(bundle, key, key, Locale.ENGLISH);
String retText = i18nProvider.getText(bundle, key, key, Locale.ENGLISH);
return retText != null ? retText : key != null ? key : "UNKNOWN_TEXT";
}
}

View File

@ -0,0 +1,75 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.utilities;
import java.lang.reflect.Type;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
/**
* Some helper method to ease and centralize use of GSON.
*
* @author Patrik Gfeller - Initial Contribution
* @author Simon Spielmann - Rename and generalization
*
*/
@NonNullByDefault
public class JsonUtils {
private static final Gson GSON = new GsonBuilder().create();
private static final Type STRING_OBJ_MAP_TYPE = new TypeToken<Map<String, Object>>() {
}.getType();
/**
* Parse JSON to {@link Map}
*
* @param json JSON String or {@code null}.
* @return Parsed data or {@code null}
* @throws JsonSyntaxException If there is a JSON syntax error.
*/
public static @Nullable Map<String, Object> toMap(@Nullable String json) throws JsonSyntaxException {
return GSON.fromJson(json, STRING_OBJ_MAP_TYPE);
}
/**
* Converts to JSON with {@link Gson}{@link #toJson(Object)}.
*
* @param data Data to convert.
* @return JSON representation of data.
*/
public static @Nullable String toJson(@Nullable Object data) {
return GSON.toJson(data);
}
/**
* Defaults to {@link Gson#fromJson(String, Class)}.
*
* @param data Data to parse.
* @param classOfT Destination type
* @param <T> Destination type param
* @return Given type or {@code null}.
*
* @see Gson#fromJson(String, Class)
*/
public static <@Nullable T> T fromJson(String data, Class<@NonNull T> classOfT) {
return GSON.fromJson(data, classOfT);
}
}

View File

@ -0,0 +1,61 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.utilities;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* This class implements util methods for list handling.
*
* @author Simon Spielmann - Initial contribution
*
*/
@NonNullByDefault
public abstract class ListUtil {
private ListUtil() {
};
/**
* Replace entries in the given originalList with entries from replacements, if the have an equal key.
*
* @param <K> Type of first pair element
* @param <V> Type of second pair element
* @param originalList List with entries to replace
* @param replacements Replacement entries
* @return New list with replaced entries
*/
public static <K extends @NonNull Object, V extends @NonNull Object> List<Pair<K, V>> replaceEntries(
List<Pair<K, V>> originalList, @Nullable List<Pair<K, V>> replacements) {
List<Pair<K, V>> result = new ArrayList<>(originalList);
if (replacements != null) {
Iterator<Pair<K, V>> it = result.iterator();
while (it.hasNext()) {
Pair<K, V> requestHeader = it.next();
for (Pair<K, V> replacementHeader : replacements) {
if (requestHeader.getKey().equals(replacementHeader.getKey())) {
it.remove();
}
}
}
result.addAll(replacements);
}
return result;
}
}

View File

@ -0,0 +1,66 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud.internal.utilities;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Implementation of simple pair. Used mainly for HTTP header handling.
*
* @author Simon Spielmann - Initial contribution.
* @param <K> Type of first element
* @param <V> Type of second element
*/
@NonNullByDefault
public class Pair<@NonNull K, @NonNull V> {
private K key;
private V value;
private Pair(K key, V value) {
this.key = key;
this.value = value;
}
/**
* Create pair with key and value. Both of type {@link String}.
*
* @param key Key
* @param value Value
* @return Pair with given key and value
*/
public static Pair<String, String> of(String key, String value) {
return new Pair<>(key, value);
}
@Override
public String toString() {
return "Pair [key=" + this.key + ", value=" + this.value + "]";
}
/**
* @return key
*/
public K getKey() {
return this.key;
}
/**
* @return value
*/
public V getValue() {
return this.value;
}
}

View File

@ -10,6 +10,8 @@ icloud.account-thing.parameter.apple-id.label=Apple Id
icloud.account-thing.parameter.apple-id.description=Apple Id (e-mail) to access iCloud information.
icloud.account-thing.parameter.password.label=Password
icloud.account-thing.parameter.password.description=Apple iCloud password for the given Id.
icloud.account-thing.parameter.code.label=Code
icloud.account-thing.parameter.code.description=Apple iCloud authentication code for 2-factor authentication.
icloud.account-thing.parameter.refresh.label=Refresh
icloud.account-thing.parameter.refresh.description=Refresh time in minutes.

View File

@ -18,6 +18,10 @@
<label>@text/icloud.account-thing.parameter.password.label</label>
<description>@text/icloud.account-thing.parameter.password.description</description>
</parameter>
<parameter name="code" type="text" required="false">
<label>@text/icloud.account-thing.parameter.code.label</label>
<description>@text/icloud.account-thing.parameter.code.description</description>
</parameter>
<parameter name="refreshTimeInMinutes" type="integer" min="5" max="65535" unit="min">
<label>@text/icloud.account-thing.parameter.refresh.label</label>
<description>@text/icloud.account-thing.parameter.refresh.description</description>

View File

@ -0,0 +1,97 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.icloud;
import static org.junit.jupiter.api.Assertions.*;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.util.List;
import java.util.Scanner;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.openhab.binding.icloud.internal.ICloudApiResponseException;
import org.openhab.binding.icloud.internal.ICloudService;
import org.openhab.binding.icloud.internal.handler.dto.json.response.ICloudAccountDataResponse;
import org.openhab.binding.icloud.internal.utilities.JsonUtils;
import org.openhab.core.storage.json.internal.JsonStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonSyntaxException;
/**
* Class to test/experiment with iCloud api.
*
* @author Simon Spielmann - Initial contribution
*/
@NonNullByDefault
public class TestICloud {
private final String iCloudTestEmail;
private final String iCloudTestPassword;
private final Logger logger = LoggerFactory.getLogger(TestICloud.class);
@BeforeEach
private void setUp() {
final Logger logger = LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
if (logger instanceof ch.qos.logback.classic.Logger) {
((ch.qos.logback.classic.Logger) logger).setLevel(ch.qos.logback.classic.Level.DEBUG);
}
}
public TestICloud() {
String sysPropMail = System.getProperty("icloud.test.email");
String sysPropPW = System.getProperty("icloud.test.pw");
iCloudTestEmail = sysPropMail != null ? sysPropMail : "notset";
iCloudTestPassword = sysPropPW != null ? sysPropPW : "notset";
}
@Test
@EnabledIfSystemProperty(named = "icloud.test.email", matches = ".*", disabledReason = "Only for manual execution.")
public void testAuth() throws IOException, InterruptedException, ICloudApiResponseException, JsonSyntaxException {
File jsonStorageFile = new File(System.getProperty("user.home"), "openhab.json");
logger.info(jsonStorageFile.toString());
JsonStorage<String> stateStorage = new JsonStorage<String>(jsonStorageFile, TestICloud.class.getClassLoader(),
0, 1000, 1000, List.of());
ICloudService service = new ICloudService(iCloudTestEmail, iCloudTestPassword, stateStorage);
service.authenticate(false);
if (service.requires2fa()) {
PrintStream consoleOutput = System.out;
if (consoleOutput != null) {
consoleOutput.print("Code: ");
}
@SuppressWarnings("resource")
Scanner in = new Scanner(System.in);
String code = in.nextLine();
assertTrue(service.validate2faCode(code));
if (!service.isTrustedSession()) {
service.trustSession();
}
if (!service.isTrustedSession()) {
logger.info("Trust failed!!!");
}
}
ICloudAccountDataResponse deviceInfo = JsonUtils.fromJson(service.getDevices().refreshClient(),
ICloudAccountDataResponse.class);
assertNotNull(deviceInfo);
stateStorage.flush();
}
}