/* * Copyright 2017 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.libraries.remixer.storage; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.support.annotation.NonNull; import com.google.android.libraries.remixer.Remixer; import com.google.android.libraries.remixer.Variable; import com.google.android.libraries.remixer.serialization.StoredVariable; import com.google.android.libraries.remixer.sync.LocalValueSyncing; import com.google.android.libraries.remixer.sync.SynchronizationMechanism; import com.google.firebase.FirebaseApp; import com.google.firebase.database.ChildEventListener; import com.google.firebase.database.DataSnapshot; import com.google.firebase.database.DatabaseError; import com.google.firebase.database.DatabaseReference; import com.google.firebase.database.FirebaseDatabase; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.UUID; /** * A {@link SynchronizationMechanism} that syncs up to a firebase Remote Controller. * * <p>This synchronization mechanism assumes that the local host is the source of truth, so it does * very little in terms of conflict resolution. */ public class FirebaseRemoteControllerSyncer extends LocalStorage implements ChildEventListener { private final Context applicationContext; /** * The reference to the root element for this Remixer Instance in the database */ private DatabaseReference reference; /** * The current context that is active on the app (foreground activity). */ private WeakReference<Object> context; /** * Objects that are listening to changes on the sharing status. */ private final HashSet<WeakReference<SharingStatusListener>> listeners; /** * Whether sharing is enabled or not. All accesses to this field must be synchronized. */ private boolean sharing = false; /** * The autogenerated id for this instance of Remixer. This is the first 8 characters of a random * UUID that would identify this device. */ private String remoteId; private static final String PREFERENCES_FILE_NAME = "remixer_firebase"; private static final String REMOTE_ID = "remote_id"; private static final String REFERENCE_FORMAT = "remixer/%s"; /** * Initializes a {@code FirebaseRemoteControllerSyncer} instance. * * <p>Uses {@code context} to get the Remote ID from shared preferences (or persist a generated * one if it does not yet exist). */ public FirebaseRemoteControllerSyncer(Context applicationContext) { super(applicationContext); SharedPreferences preferences = applicationContext.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE); remoteId = preferences.getString(REMOTE_ID, null); if (remoteId == null) { remoteId = UUID.randomUUID().toString().substring(0, 7); preferences.edit().putString(REMOTE_ID, remoteId).apply(); } this.applicationContext = applicationContext; listeners = new HashSet<>(); } /** * Starts sharing up to Firebase. * * <p>Since we don't know what the status of the database is right now, we clear the value and * then add everything for the current context, then add this instance as a child event listener. * * <p>That way we get notified of changes to children (individual variables) after the initial * sync. */ public synchronized void startSharing() { reference = FirebaseDatabase.getInstance().getReference( String.format(Locale.getDefault(), REFERENCE_FORMAT, remoteId)); reference.removeValue(); sharing = true; if (context != null && context.get() != null) { List<Variable> variableList = Remixer.getInstance().getVariablesWithContext(context.get()); if (variableList != null) { for (Variable variable : variableList) { syncVariableToRemoteController(StoredVariable.fromVariable(variable)); } } } reference.addChildEventListener(this); notifyListeners(); } /** * Stops sharing to firebase, removes this instance as listener for changes and clears the values * in Firebase. */ public synchronized void stopSharing() { if (sharing) { sharing = false; reference.removeEventListener(this); reference.removeValue(); } notifyListeners(); } private void notifyListeners() { ArrayList<WeakReference<SharingStatusListener>> remove = null; for (WeakReference<SharingStatusListener> reference : listeners) { if (reference.get() == null) { if (remove == null) { remove = new ArrayList<>(); } remove.add(reference); } else { reference.get().updateSharingStatus(sharing); } } if (remove != null) { for (WeakReference<SharingStatusListener> reference : remove) { listeners.remove(reference); } } } /** * Returns true if it is currently sharing up to firebase. */ public boolean isSharing() { return sharing; } /** * Syncs a variable up to the remote controller. * * <p>Since the local app is the source of truth, this ignores any differences there may be * between the local data and the remote data and just rewrites any remote data. This should not * happen in practice though, since variables in remote controllers are tied to a single instance * of the app (one specific device running the app). */ private synchronized void syncVariableToRemoteController(StoredVariable variable) { if (sharing) { reference.child(variable.getKey()).setValue(variable); } } // Overrides from LocalValueSyncing @Override public void onAddingVariable(Variable variable) { super.onAddingVariable(variable); syncVariableToRemoteController(StoredVariable.fromVariable(variable)); } @Override public void onValueChanged(Variable variable) { super.onValueChanged(variable); syncVariableToRemoteController(StoredVariable.fromVariable(variable)); } @Override public void onContextChanged(Object currentContext) { super.onContextChanged(currentContext); if ((context == null && currentContext != null) || (context != null && currentContext != context.get())) { context = new WeakReference<Object>(currentContext); if (reference != null) { reference.removeValue(); } List<Variable> variables = Remixer.getInstance().getVariablesWithContext(currentContext); if (variables != null) { for (Variable<?> var : variables) { syncVariableToRemoteController(StoredVariable.fromVariable(var)); } } } } @Override public void onContextRemoved(Object currentContext) { super.onContextRemoved(currentContext); if (context != null && context.get() == currentContext) { if (reference != null) { reference.removeValue(); } context = null; } } // Implementation of ChildEventListener @Override public void onChildAdded(DataSnapshot dataSnapshot, String childKey) { // Add it to serializableRemixerContents if it does not exist. If it does exist let the local // value take precedence and ignore the one coming from firebase. if (!serializableRemixerContents.keySet().contains(childKey)) { serializableRemixerContents.addItem( FirebaseSerializationHelper.deserializeStoredVariable(dataSnapshot)); } } @Override @SuppressWarnings("unchecked") // There is no way to check this without knowing the type in advance public void onChildChanged(DataSnapshot dataSnapshot, String childKey) { StoredVariable storedVariable = FirebaseSerializationHelper.deserializeStoredVariable(dataSnapshot); serializableRemixerContents.setValue(storedVariable); Object valueFromFirebase = Remixer.getDataType(storedVariable.getDataType()) .getConverter().toRuntimeType(storedVariable.getSelectedValue()); for (Variable variable : Remixer.getInstance().getVariablesWithKey(storedVariable.getKey())) { variable.setValueWithoutNotifyingOthers(valueFromFirebase); } } @Override public void onChildRemoved(DataSnapshot dataSnapshot) { // This shouldn't happen. } @Override public void onChildMoved(DataSnapshot dataSnapshot, String childKey) { // This shouldn't happen. } @Override public void onCancelled(DatabaseError databaseError) { // Ignoring errors for the time being } public void addSharingStatusListener(@NonNull SharingStatusListener listener) { listener.updateSharingStatus(sharing); listeners.add(new WeakReference<>(listener)); } public Intent getShareLinkIntent() { Intent intent = new Intent(); intent.setAction(Intent.ACTION_SEND); String url = getRemoteUrl(); String message = applicationContext.getResources().getString(R.string.shareLinkFormat, url); intent.putExtra(Intent.EXTRA_TEXT, message); intent.setType("text/plain"); return intent; } /** * Returns the URL that points to the remote controller. */ public String getRemoteUrl() { return String.format( Locale.getDefault(), "https://%s.firebaseapp.com/%s", getUrlPrefix(), remoteId); } private String getUrlPrefix() { String url = FirebaseApp.getInstance().getOptions().getStorageBucket(); if (url.contains(".")) { return url.substring(0, url.indexOf('.')); } return url; } /** * Implement this to register as a listener for changes in the sharing status using * {@link #addSharingStatusListener(SharingStatusListener)}. */ public interface SharingStatusListener { /** * Notify that the FirebaseRemoteControllerSyncer has started or stopped sharing. */ void updateSharingStatus(boolean sharing); } }