[ShareLocation] Add subscriber logic

Now other people are allowed to follow a shared location as well
while the app is running.
This commit is contained in:
Alexander Dörflinger
2025-03-21 11:30:53 +01:00
parent 5ec30a966f
commit ae50dd89df
13 changed files with 588 additions and 69 deletions

View File

@@ -3,20 +3,7 @@
<component name="deploymentTargetDropDown"> <component name="deploymentTargetDropDown">
<value> <value>
<entry key="app"> <entry key="app">
<State> <State />
<runningDeviceTargetSelectedWithDropDown>
<Target>
<type value="RUNNING_DEVICE_TARGET" />
<deviceKey>
<Key>
<type value="SERIAL_NUMBER" />
<value value="47111FDAS0039V" />
</Key>
</deviceKey>
</Target>
</runningDeviceTargetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2025-03-19T15:23:01.624733903Z" />
</State>
</entry> </entry>
</value> </value>
</component> </component>

View File

@@ -44,6 +44,8 @@ dependencies {
implementation 'com.github.bumptech.glide:glide:4.16.0' // Check for the latest version implementation 'com.github.bumptech.glide:glide:4.16.0' // Check for the latest version
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0' annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
//CircleImageView
implementation 'de.hdodenhof:circleimageview:3.1.0'
//Firebase Dependencies //Firebase Dependencies
implementation platform('com.google.firebase:firebase-bom:33.10.0') implementation platform('com.google.firebase:firebase-bom:33.10.0')

View File

@@ -97,9 +97,8 @@ public class HelperGridActivity extends AppCompatActivity {
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
mLocationHelper = LocationHelper.getInstance(this);
mWelcomeMessageView = findViewById(R.id.tv_welcome_message); mWelcomeMessageView = findViewById(R.id.tv_welcome_message);
if (mCurrentUser == null) { if (mCurrentUser == null || mCurrentUser.isAnonymous() || mCurrentUser.getUid() == null) {
final Intent signInIntent = AuthUI.getInstance() final Intent signInIntent = AuthUI.getInstance()
.createSignInIntentBuilder() .createSignInIntentBuilder()
.setAvailableProviders(SIGN_IN_PROVIDERS) .setAvailableProviders(SIGN_IN_PROVIDERS)
@@ -110,6 +109,7 @@ public class HelperGridActivity extends AppCompatActivity {
mWelcomeMessageView.setText(String.format(getString(R.string.welcome_message_placeholder), mWelcomeMessageView.setText(String.format(getString(R.string.welcome_message_placeholder),
mCurrentUser.getDisplayName())); mCurrentUser.getDisplayName()));
mDbHelper = new DatabaseHelper(); mDbHelper = new DatabaseHelper();
mLocationHelper = LocationHelper.getInstance(this);
} }
} }
@@ -161,7 +161,6 @@ public class HelperGridActivity extends AppCompatActivity {
Log.d(TAG, "launchHelper: Clicked ShareLocation"); Log.d(TAG, "launchHelper: Clicked ShareLocation");
if (mLocationHelper.requestLocationPermissions(HelperGridActivity.this)) { if (mLocationHelper.requestLocationPermissions(HelperGridActivity.this)) {
Log.d(TAG, "launchHelper: Permission already granted"); Log.d(TAG, "launchHelper: Permission already granted");
mLocationHelper.toggleUpdate(HelperGridActivity.this);
final Intent intent = new Intent(HelperGridActivity.this, ShareLocationActivity.class); final Intent intent = new Intent(HelperGridActivity.this, ShareLocationActivity.class);
startActivity(intent); startActivity(intent);
} }
@@ -205,6 +204,7 @@ public class HelperGridActivity extends AppCompatActivity {
mWelcomeMessageView.setText(String.format(getString(R.string.welcome_message_placeholder), mWelcomeMessageView.setText(String.format(getString(R.string.welcome_message_placeholder),
mCurrentUser.getDisplayName())); mCurrentUser.getDisplayName()));
mDbHelper = new DatabaseHelper(); mDbHelper = new DatabaseHelper();
mLocationHelper = LocationHelper.getInstance(this);
} else { } else {
Log.w(TAG, "onSignInResult: Sign-In failed"); Log.w(TAG, "onSignInResult: Sign-In failed");
mWelcomeMessageView.setText(String.format(getString(R.string.welcome_message_placeholder), mWelcomeMessageView.setText(String.format(getString(R.string.welcome_message_placeholder),
@@ -224,7 +224,8 @@ public class HelperGridActivity extends AppCompatActivity {
@NonNull final int[] grantResults) { @NonNull final int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults); super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (mLocationHelper.handlePermissionResult(requestCode, grantResults)) { if (mLocationHelper.handlePermissionResult(requestCode, grantResults)) {
mLocationHelper.toggleUpdate(HelperGridActivity.this); final Intent intent = new Intent(HelperGridActivity.this, ShareLocationActivity.class);
startActivity(intent);
} }
} }

View File

@@ -1,18 +1,32 @@
package com.aldo.apps.familyhelpers; package com.aldo.apps.familyhelpers;
import static com.aldo.apps.familyhelpers.utils.GlobalConstants.DEFAULT_HOME_LATITUDE;
import static com.aldo.apps.familyhelpers.utils.GlobalConstants.DEFAULT_HOME_LONGITUDE;
import static com.aldo.apps.familyhelpers.utils.GlobalConstants.DEFAULT_MINIMUM_LOCATION_INTERVAL_METERS;
import static com.aldo.apps.familyhelpers.utils.GlobalConstants.DEFAULT_MINIMUM_LOCATION_INTERVAL_MILLIS;
import static com.aldo.apps.familyhelpers.utils.GlobalConstants.METER_PER_SECOND_TO_KMH_CONVERTER; import static com.aldo.apps.familyhelpers.utils.GlobalConstants.METER_PER_SECOND_TO_KMH_CONVERTER;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.view.View;
import android.widget.CompoundButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.aldo.apps.familyhelpers.model.LocationObject; import com.aldo.apps.familyhelpers.model.LocationObject;
import com.aldo.apps.familyhelpers.model.User;
import com.aldo.apps.familyhelpers.ui.ActiveShareAdapter;
import com.aldo.apps.familyhelpers.workers.DatabaseHelper; import com.aldo.apps.familyhelpers.workers.DatabaseHelper;
import com.aldo.apps.familyhelpers.workers.LocationHelper;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.google.android.gms.maps.CameraUpdateFactory; import com.google.android.gms.maps.CameraUpdateFactory;
import com.google.android.gms.maps.GoogleMap; import com.google.android.gms.maps.GoogleMap;
@@ -22,12 +36,19 @@ import com.google.android.gms.maps.model.LatLng;
import com.google.android.gms.maps.model.MapStyleOptions; import com.google.android.gms.maps.model.MapStyleOptions;
import com.google.android.gms.maps.model.Marker; import com.google.android.gms.maps.model.Marker;
import com.google.android.gms.maps.model.MarkerOptions; import com.google.android.gms.maps.model.MarkerOptions;
import com.google.android.material.materialswitch.MaterialSwitch;
import com.google.firebase.auth.FirebaseAuth; import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseUser; import com.google.firebase.auth.FirebaseUser;
import org.checkerframework.checker.units.qual.A;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
@@ -97,6 +118,18 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
*/ */
private Disposable mLocationUpdateSubscription; private Disposable mLocationUpdateSubscription;
/**
* The {@link Disposable} holding the subscription to the {@link String}
* {@link io.reactivex.rxjava3.subjects.BehaviorSubject} in order to know whom to follow.
*/
private Disposable mCurrentlyFollowingSubscription;
/**
* The {@link Disposable} holding the subscription to {@link User}
* {@link io.reactivex.rxjava3.subjects.BehaviorSubject} in order to have it cancellable.
*/
private Disposable mUserSubscription;
/** /**
* Boolean flag indicating whether the system is currently in night mode or not. * Boolean flag indicating whether the system is currently in night mode or not.
*/ */
@@ -107,6 +140,36 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
*/ */
private Marker mGmapsMarker; private Marker mGmapsMarker;
/**
* Map containing all markers and the corresponding ID.
*/
private final Map<String, Marker> mMarkerMap = new HashMap<>();
/**
* {@link List} of all {@link User}s.
*/
private final List<User> mAllUsers = new ArrayList<>();
/**
* The ID of who the user is currently following.
*/
private String mCurrentlyFollowing;
/**
* Displays an error if no active shares.
*/
private TextView mNoActiveShares;
/**
* Displays icons of the users of active shares.
*/
private RecyclerView mActiveShares;
/**
* The {@link ActiveShareAdapter} to populate the activeShare view.
*/
private ActiveShareAdapter mActiveShareAdapter;
@Override @Override
protected void onCreate(final Bundle savedInstanceState) { protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
@@ -116,14 +179,34 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
.findFragmentById(R.id.map); .findFragmentById(R.id.map);
mCurrentUser = FirebaseAuth.getInstance().getCurrentUser(); mCurrentUser = FirebaseAuth.getInstance().getCurrentUser();
mInfoBoxIcon = findViewById(R.id.share_location_info_user_icon); mInfoBoxIcon = findViewById(R.id.share_location_info_user_icon);
Glide.with(this)
.load(mCurrentUser.getPhotoUrl())
.into(mInfoBoxIcon);
mInfoBoxTitle = findViewById(R.id.share_location_info_title); mInfoBoxTitle = findViewById(R.id.share_location_info_title);
mInfoBoxLocation = findViewById(R.id.share_location_info_location); mInfoBoxLocation = findViewById(R.id.share_location_info_location);
mInfoBoxAltitude = findViewById(R.id.share_location_info_altitude); mInfoBoxAltitude = findViewById(R.id.share_location_info_altitude);
mInfoBoxSpeed = findViewById(R.id.share_location_info_speed); mInfoBoxSpeed = findViewById(R.id.share_location_info_speed);
mInfoBoxTimeStamp = findViewById(R.id.share_location_info_timestamp); mInfoBoxTimeStamp = findViewById(R.id.share_location_info_timestamp);
mNoActiveShares = findViewById(R.id.tv_no_active_shares);
mActiveShares = findViewById(R.id.active_share_layout);
final MaterialSwitch shareLocationToggle = findViewById(R.id.switch_start_stop_sharing_location);
final LocationHelper locationHelper = LocationHelper.getInstance(this);
shareLocationToggle.setChecked(locationHelper.isCurrentlySharing());
shareLocationToggle.setOnCheckedChangeListener((buttonView, isChecked) -> {
if (isChecked) {
locationHelper.startLocationUpdates(ShareLocationActivity.this,
DEFAULT_MINIMUM_LOCATION_INTERVAL_MILLIS,
DEFAULT_MINIMUM_LOCATION_INTERVAL_METERS)
;
} else {
locationHelper.stopLocationUpdates();
}
});
locationHelper.getSharingStateSubject().subscribe(isSharing -> shareLocationToggle.setChecked(isSharing),
this::handleSubscriptionError);
mActiveShares.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
mActiveShareAdapter = new ActiveShareAdapter(this);
mActiveShares.setAdapter(mActiveShareAdapter);
mapFragment.getMapAsync(this); mapFragment.getMapAsync(this);
mDbHelper = new DatabaseHelper(); mDbHelper = new DatabaseHelper();
} }
@@ -139,30 +222,167 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
if (mShareId == null) { if (mShareId == null) {
mShareId = mCurrentUser.getUid(); mShareId = mCurrentUser.getUid();
} }
mDbHelper.startListeningForLocationUpdates(mShareId);
if (mLocationUpdateSubscription != null) { if (mLocationUpdateSubscription != null) {
mLocationUpdateSubscription.dispose(); mLocationUpdateSubscription.dispose();
} }
mLocationUpdateSubscription = mDbHelper.getLocationSubject() mUserSubscription = mDbHelper.getAllUsers()
.subscribe(this::handleReceivedLocation, this::handleSubscriptionError); .subscribe(this::handleAllUsers, this::handleUserSubscriptionFailed);
mLocationUpdateSubscription = mDbHelper.subscribeToAllLocationUpdates()
.subscribe(this::handleReceivedLocations, this::handleSubscriptionError);
mCurrentlyFollowingSubscription = mActiveShareAdapter.getCurrentlySelectedSubject()
.subscribe(this::handleCurrentlyFollowingChanged, this::handleSubscriptionError);
} }
@Override @Override
protected void onPause() { protected void onPause() {
super.onPause(); super.onPause();
if (mLocationUpdateSubscription != null) { if (mUserSubscription != null && !mUserSubscription.isDisposed()) {
mUserSubscription.dispose();
}
if (mCurrentlyFollowingSubscription != null && !mCurrentlyFollowingSubscription.isDisposed()) {
mCurrentlyFollowingSubscription.dispose();
}
if (mLocationUpdateSubscription != null && !mLocationUpdateSubscription.isDisposed()) {
mLocationUpdateSubscription.dispose(); mLocationUpdateSubscription.dispose();
} }
} }
/** /**
* Helper method to handle the received location by updating both the Map and the Info box. * Helper method to handle all received locations. Will remove existing invalid, update existing
* valid and add newly added Markers to the map.
* *
* @param locationObject The received {@link LocationObject} holding the new information. * @param locationObjectMap The {@link Map} matching shareIds to {@link LocationObject}s.
*/ */
private void handleReceivedLocation(final LocationObject locationObject) { private void handleReceivedLocations(final Map<String, LocationObject> locationObjectMap) {
Log.d(TAG, "handleReceivedLocation() called with: locationObject = [" + locationObject + "]"); if (TextUtils.isEmpty(mCurrentlyFollowing)) {
mInfoBoxTitle.setText(String.format(getString(R.string.share_location_info_title), mCurrentUser.getDisplayName())); clearInfoBox();
}
// If empty received, remove all markers.
if (locationObjectMap.isEmpty()) {
for (final Marker marker : mMarkerMap.values()) {
marker.remove();
}
mMarkerMap.clear();
clearInfoBox();
mNoActiveShares.setVisibility(View.VISIBLE);
mActiveShares.setVisibility(View.GONE);
} else {
mNoActiveShares.setVisibility(View.GONE);
mActiveShares.setVisibility(View.VISIBLE);
}
if (mGmapsMarker != null) {
mGmapsMarker.remove();
}
//Update the available active shares.
updateActiveShares(locationObjectMap);
// If non-empty received, remove all invalid existing markers
removeInvalidExistingMarkers(locationObjectMap);
// Update/Insert all existing/new markers
updateExistingOrCreateValidMarkers(locationObjectMap);
}
private void updateActiveShares(final Map<String, LocationObject> locationObjectMap) {
final List<User> activeShares = new ArrayList<>();
for (final String shareId : locationObjectMap.keySet()) {
activeShares.add(mDbHelper.getUserForId(shareId));
}
mActiveShareAdapter.applyNewData(activeShares);
}
/**
* Removed all markers that became invalid because the sharing was stopped.
*
* @param locationObjectMap The {@link Map} matching shareIds to {@link LocationObject}s.
*/
private void removeInvalidExistingMarkers(final Map<String, LocationObject> locationObjectMap) {
final List<String> toBeRemovedKeys = new ArrayList<>();
for (Map.Entry<String, Marker> markerEntry : mMarkerMap.entrySet()) {
if (!locationObjectMap.containsKey(markerEntry.getKey())) {
Log.d(TAG, "handleReceivedLocations: Marker not valid anymore, remove");
markerEntry.getValue().remove();
toBeRemovedKeys.add(markerEntry.getKey());
}
}
// Remove from the map as well
for (final String removeKey : toBeRemovedKeys) {
if (TextUtils.equals(removeKey, mCurrentlyFollowing)) {
clearInfoBox();
mCurrentlyFollowing = null;
}
mMarkerMap.remove(removeKey);
}
}
/**
* Helper method to update or create the valid markers on the map. Updates the position of a
* marker if it is existing before, adds a new marker if nor yet existing.
*
* @param locationObjectMap The {@link Map} matching shareIds to {@link LocationObject}s.
*/
private void updateExistingOrCreateValidMarkers(final Map<String, LocationObject> locationObjectMap) {
for (final Map.Entry<String, LocationObject> locationSet : locationObjectMap.entrySet()) {
final LocationObject newReceived = locationSet.getValue();
final LatLng receivedLocation = new LatLng(newReceived.getLatitude(), newReceived.getLongitude());
final String sharingId = locationSet.getKey();
final User locationUser = mDbHelper.getUserForId(sharingId);
if (TextUtils.equals(mCurrentlyFollowing, sharingId)) {
final LocationObject locationObject = locationSet.getValue();
updateInfoBox(locationObject);
mGmap.animateCamera(CameraUpdateFactory.newLatLngZoom(
new LatLng(locationObject.getLatitude(), locationObject.getLongitude()),
15.0f));
}
if (mMarkerMap.containsKey(sharingId)) {
final Marker existing = mMarkerMap.get(sharingId);
if (existing == null) {
Log.w(TAG, "updateExistingOrCreateValidMarkers: Marker somehow not existing, skip");
continue;
}
Log.d(TAG, "handleReceivedLocations: Marker still available, update position");
existing.setPosition(receivedLocation);
} else {
Log.d(TAG, "handleReceivedLocations: New Marker to be created.");
final Marker marker = mGmap.addMarker(new MarkerOptions()
.position(receivedLocation)
.title(locationUser == null ? "" : locationUser.getDisplayName()));
mMarkerMap.put(locationSet.getKey(), marker);
}
}
}
/**
* Helper method to clear the info box.
*/
private void clearInfoBox() {
mInfoBoxTitle.setText(R.string.share_location_info_title_empty);
mInfoBoxLocation.setText("");
mInfoBoxAltitude.setText("");
mInfoBoxSpeed.setText("");
mInfoBoxTimeStamp.setText("");
mInfoBoxIcon.setImageResource(R.drawable.ic_unknown_user);
}
/**
* Update the info box with information of a LocationShare.
*
* @param locationObject The {@link LocationObject} holding the information.
*/
private void updateInfoBox(final LocationObject locationObject) {
if (mCurrentlyFollowing != null) {
final User followed = mDbHelper.getUserForId(mCurrentlyFollowing);
if (followed != null) {
mInfoBoxTitle.setText(String.format(getString(R.string.share_location_info_title),
followed.getDisplayName()));
Glide.with(this)
.load(Uri.parse(followed.getPhotoUrl()))
.centerCrop()
.into(mInfoBoxIcon);
}
}
mInfoBoxLocation.setText(String.format(getString(R.string.share_location_info_location), mInfoBoxLocation.setText(String.format(getString(R.string.share_location_info_location),
locationObject.getLatitude(), locationObject.getLongitude())); locationObject.getLatitude(), locationObject.getLongitude()));
mInfoBoxAltitude.setText(String.format(getString(R.string.share_location_info_altitude), mInfoBoxAltitude.setText(String.format(getString(R.string.share_location_info_altitude),
@@ -170,19 +390,8 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
mInfoBoxSpeed.setText(String.format(getString(R.string.share_location_info_speed), mInfoBoxSpeed.setText(String.format(getString(R.string.share_location_info_speed),
locationObject.getSpeed(), locationObject.getSpeed(),
locationObject.getSpeed() * METER_PER_SECOND_TO_KMH_CONVERTER)); locationObject.getSpeed() * METER_PER_SECOND_TO_KMH_CONVERTER));
mInfoBoxTimeStamp.setText(String.format(getString(R.string.share_location_info_timestamp), formatTime(locationObject.getTimestamp()))); mInfoBoxTimeStamp.setText(String.format(getString(R.string.share_location_info_timestamp),
formatTime(locationObject.getTimestamp())));
final LatLng received = new LatLng(locationObject.getLatitude(), locationObject.getLongitude());
if (mGmapsMarker == null) {
mGmap.addMarker(new MarkerOptions()
.position(received)
.title(mCurrentUser.getDisplayName()));
} else {
mGmapsMarker.setPosition(received);
}
mGmap.moveCamera(CameraUpdateFactory.newLatLngZoom(received, 15.0f));
} }
/** /**
@@ -198,7 +407,6 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
* Formats the provided time in milliseconds in a human readable format. * Formats the provided time in milliseconds in a human readable format.
* *
* @param millis The remaining time in milliseconds. * @param millis The remaining time in milliseconds.
*
* @return The String representation of the milliseconds. * @return The String representation of the milliseconds.
*/ */
private String formatTime(final long millis) { private String formatTime(final long millis) {
@@ -207,6 +415,42 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
return simpleDateFormat.format(date); return simpleDateFormat.format(date);
} }
/**
* Handle all available users.
*
* @param allUsers The {@link List} of available users.
*/
private void handleAllUsers(final List<User> allUsers) {
mAllUsers.clear();
mAllUsers.addAll(allUsers);
}
/**
* Handlles the callback when the selection of currently sharedd Id changes. Re-Established the
* subscription to all locations and updates the info box.
*
* @param shareId The ID of the user to be followed.
*/
private void handleCurrentlyFollowingChanged(final String shareId) {
Log.d(TAG, "onCreate: Currently selected = [" + shareId + "]");
mCurrentlyFollowing = shareId;
// Re-setup the subscription.
if (mLocationUpdateSubscription != null) {
mLocationUpdateSubscription.dispose();
}
mLocationUpdateSubscription = mDbHelper.subscribeToAllLocationUpdates()
.subscribe(this::handleReceivedLocations, this::handleSubscriptionError);
}
/**
* Handle subscription to users failing.
*
* @param throwable The {@link Throwable} associated.
*/
private void handleUserSubscriptionFailed(final Throwable throwable) {
Log.e(TAG, "handleUserSubscriptionFailed: Subscription to all users failed, keep cached state");
}
@Override @Override
public void onMapReady(@NonNull final GoogleMap googleMap) { public void onMapReady(@NonNull final GoogleMap googleMap) {
mGmap = googleMap; mGmap = googleMap;
@@ -215,17 +459,15 @@ public class ShareLocationActivity extends AppCompatActivity implements OnMapRea
} else { } else {
mGmap.setMapStyle(MapStyleOptions.loadRawResourceStyle(this, R.raw.map_style_day)); mGmap.setMapStyle(MapStyleOptions.loadRawResourceStyle(this, R.raw.map_style_day));
} }
final LatLng defaultHome = new LatLng(48.41965746149261, 9.909289365473684); final LatLng defaultHome = new LatLng(DEFAULT_HOME_LATITUDE, DEFAULT_HOME_LONGITUDE);
if (mGmapsMarker == null) { if (mGmapsMarker == null) {
mGmap.addMarker(new MarkerOptions() mGmapsMarker = mGmap.addMarker(new MarkerOptions()
.position(defaultHome) .position(defaultHome)
.title("Default - Home")); .title("Default - Home"));
} else { } else {
mGmapsMarker.setPosition(defaultHome); mGmapsMarker.setPosition(defaultHome);
} }
mGmap.moveCamera(CameraUpdateFactory.newLatLngZoom(defaultHome, 15.0f)); mGmap.moveCamera(CameraUpdateFactory.newLatLngZoom(defaultHome, 8.0f));
} }
} }

View File

@@ -115,4 +115,14 @@ public class User {
} }
return new User(uid,displayName, photoUrl == null ? null : photoUrl.toString(), creationDate); return new User(uid,displayName, photoUrl == null ? null : photoUrl.toString(), creationDate);
} }
@Override
public String toString() {
return "User{" +
"uId='" + uId + '\'' +
", displayName='" + displayName + '\'' +
", photoUrl='" + photoUrl + '\'' +
", creationDate=" + creationDate +
'}';
}
} }

View File

@@ -0,0 +1,109 @@
package com.aldo.apps.familyhelpers.ui;
import android.content.Context;
import android.graphics.PorterDuff;
import android.net.Uri;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.RecyclerView;
import com.aldo.apps.familyhelpers.R;
import com.aldo.apps.familyhelpers.model.User;
import com.bumptech.glide.Glide;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import de.hdodenhof.circleimageview.CircleImageView;
import io.reactivex.rxjava3.subjects.BehaviorSubject;
public class ActiveShareAdapter extends RecyclerView.Adapter<ActiveShareAdapter.ActiveShareViewHolder> {
private static final String TAG = "ActiveShareAdapter";
private final BehaviorSubject<String> mCurrentlySelectedSubject = BehaviorSubject.createDefault("");
private final List<User> mSharingUserList = new ArrayList<>();
private final WeakReference<Context> mContextRef;
public ActiveShareAdapter(final Context context) {
mContextRef = new WeakReference<>(context);
}
@NonNull
@Override
public ActiveShareViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
final View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.active_share_view_holder, parent, false);
return new ActiveShareViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull final ActiveShareViewHolder holder, final int position) {
final User user = mSharingUserList.get(position);
Log.d(TAG, "onBindViewHolder: called with user [" + user + "]");
holder.setUserData(user);
}
@Override
public int getItemCount() {
return mSharingUserList.size();
}
public void applyNewData(final List<User> newUsers) {
mSharingUserList.clear();
mSharingUserList.addAll(newUsers);
notifyDataSetChanged();
}
public BehaviorSubject<String> getCurrentlySelectedSubject() {
return mCurrentlySelectedSubject;
}
public class ActiveShareViewHolder extends RecyclerView.ViewHolder {
private CircleImageView mProfilePicture;
private TextView mName;
private View mItemView;
public ActiveShareViewHolder(@NonNull final View itemView) {
super(itemView);
mProfilePicture = itemView.findViewById(R.id.active_share_profile_picture);
mName = itemView.findViewById(R.id.active_share_name);
mItemView = itemView;
}
public void setUserData(final User user) {
if (user == null) {
Log.w(TAG, "setUserData: Skip update, no valid update yet");
return;
}
mItemView.setOnClickListener(v -> {
Log.d(TAG, "setUserData: Clicked on [" + user + "]");
mCurrentlySelectedSubject.onNext(user.getuId());
});
mName.setText(user.getDisplayName());
final Context context = mContextRef.get();
if (context == null) {
mProfilePicture.setImageResource(R.drawable.ic_unknown_user);
return;
}
Glide.with(context)
.load(Uri.parse(user.getPhotoUrl()))
.centerCrop()
.dontAnimate()
.placeholder(R.drawable.ic_unknown_user)
.into(mProfilePicture);
}
}
}

View File

@@ -73,8 +73,21 @@ public final class GlobalConstants {
*/ */
public static final int DEFAULT_MINIMUM_LOCATION_INTERVAL_METERS = 5; public static final int DEFAULT_MINIMUM_LOCATION_INTERVAL_METERS = 5;
/**
* Conversion constants to convert m/s into km/h
*/
public static final double METER_PER_SECOND_TO_KMH_CONVERTER = 3.6; public static final double METER_PER_SECOND_TO_KMH_CONVERTER = 3.6;
/**
* Latitude of the default home (== Blaustein)
*/
public static final double DEFAULT_HOME_LATITUDE = 48.41965746149261;
/**
* Longitude of the default home (== Blaustein)
*/
public static final double DEFAULT_HOME_LONGITUDE = 9.909289365473684;
/** /**
* List of available Firebase signIn/Login providers. * List of available Firebase signIn/Login providers.
*/ */

View File

@@ -6,15 +6,23 @@ import static com.aldo.apps.familyhelpers.utils.DatabaseConstants.DB_DOC_USER_ID
import android.util.Log; import android.util.Log;
import androidx.annotation.Nullable;
import com.aldo.apps.familyhelpers.model.LocationObject; import com.aldo.apps.familyhelpers.model.LocationObject;
import com.aldo.apps.familyhelpers.model.User; import com.aldo.apps.familyhelpers.model.User;
import com.google.firebase.auth.FirebaseAuth; import com.google.firebase.auth.FirebaseAuth;
import com.google.firebase.auth.FirebaseUser; import com.google.firebase.auth.FirebaseUser;
import com.google.firebase.firestore.DocumentSnapshot;
import com.google.firebase.firestore.EventListener;
import com.google.firebase.firestore.FirebaseFirestore; import com.google.firebase.firestore.FirebaseFirestore;
import com.google.firebase.firestore.FirebaseFirestoreException;
import com.google.firebase.firestore.ListenerRegistration; import com.google.firebase.firestore.ListenerRegistration;
import com.google.firebase.firestore.QuerySnapshot;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import io.reactivex.rxjava3.subjects.BehaviorSubject; import io.reactivex.rxjava3.subjects.BehaviorSubject;
@@ -48,8 +56,16 @@ public class DatabaseHelper {
*/ */
private BehaviorSubject<LocationObject> mObservedLocation = BehaviorSubject.create(); private BehaviorSubject<LocationObject> mObservedLocation = BehaviorSubject.create();
/**
* The {@link ListenerRegistration} to listen for location updates.
*/
private ListenerRegistration mLocationUpdateListener; private ListenerRegistration mLocationUpdateListener;
/**
* {@link Map} containing all users.
*/
private Map<String, User> mAllUserMap = new HashMap<>();
/** /**
* C'tor. * C'tor.
*/ */
@@ -73,7 +89,6 @@ public class DatabaseHelper {
*/ */
private void subscribeToAllUsers() { private void subscribeToAllUsers() {
mDatabase.collection(DB_COLL_USERS) mDatabase.collection(DB_COLL_USERS)
.whereNotEqualTo(DB_DOC_USER_ID, mCurrentUser.getUid())
.addSnapshotListener((value, error) -> { .addSnapshotListener((value, error) -> {
if (error != null) { if (error != null) {
Log.e(TAG, "onEvent: ", error); Log.e(TAG, "onEvent: ", error);
@@ -82,8 +97,12 @@ public class DatabaseHelper {
if (value != null && !value.isEmpty()) { if (value != null && !value.isEmpty()) {
final List<User> allUsers = new ArrayList<>(); final List<User> allUsers = new ArrayList<>();
value.getDocuments() value.getDocuments()
.forEach(documentSnapshot .forEach(documentSnapshot -> {
-> allUsers.add(documentSnapshot.toObject(User.class))); final User user = documentSnapshot.toObject(User.class);
Log.d(TAG, "subscribeToAllUsers: Got user [" + user + "]");
allUsers.add(user);
mAllUserMap.put(user.getuId(), user);
});
mAllUsers.onNext(allUsers); mAllUsers.onNext(allUsers);
} else { } else {
Log.w(TAG, "onEvent: Read failed, do not update local values."); Log.w(TAG, "onEvent: Read failed, do not update local values.");
@@ -100,6 +119,16 @@ public class DatabaseHelper {
return mAllUsers; return mAllUsers;
} }
public User getUserForId(final String userId) {
Log.d(TAG, "getUserForId() called with: userId = [" + userId + "]");
if (mAllUserMap.containsKey(userId)) {
Log.d(TAG, "getUserForId: Returning [" + mAllUserMap.get(userId) + "]");
return mAllUserMap.get(userId);
}
Log.d(TAG, "getUserForId: returning null");
return null;
}
/** /**
* Returns the {@link BehaviorSubject} containing the {@link LocationObject} to be observed. * Returns the {@link BehaviorSubject} containing the {@link LocationObject} to be observed.
* *
@@ -127,6 +156,9 @@ public class DatabaseHelper {
*/ */
public void startListeningForLocationUpdates(final String shareId) { public void startListeningForLocationUpdates(final String shareId) {
mObservedLocation = BehaviorSubject.create(); mObservedLocation = BehaviorSubject.create();
if (mLocationUpdateListener != null) {
mLocationUpdateListener.remove();
}
mLocationUpdateListener = mDatabase.collection(DB_COLL_LOCATION) mLocationUpdateListener = mDatabase.collection(DB_COLL_LOCATION)
.document(shareId) .document(shareId)
.addSnapshotListener((value, error) -> { .addSnapshotListener((value, error) -> {
@@ -144,6 +176,37 @@ public class DatabaseHelper {
}); });
} }
/**
* Helper method to start listening for all available location sharings in progress.
*
* @return The {@link BehaviorSubject} containing a {@link Map} of all {@link LocationObject} matched
* to the sharing ID.
*/
public BehaviorSubject<Map<String, LocationObject>> subscribeToAllLocationUpdates() {
final Map<String, LocationObject> locationMap = new HashMap<>();
final BehaviorSubject<Map<String, LocationObject>> behaviorSubject
= BehaviorSubject.createDefault(locationMap);
if (mLocationUpdateListener != null) {
mLocationUpdateListener.remove();
}
mLocationUpdateListener = mDatabase.collection(DB_COLL_LOCATION)
.addSnapshotListener((value, error) -> {
if (value == null || value.isEmpty()) {
Log.d(TAG, "onEvent: No ongoing location shares, return empty map.");
behaviorSubject.onNext(new HashMap<>());
return;
}
final List<DocumentSnapshot> allDocs = value.getDocuments();
locationMap.clear();
for (final DocumentSnapshot documentSnapshot : allDocs) {
locationMap.put(documentSnapshot.getId(), documentSnapshot.toObject(LocationObject.class));
}
behaviorSubject.onNext(locationMap);
});
return behaviorSubject;
}
/** /**
* Cancels (by deleting) the ongoing {@link LocationObject} sharing in the database. * Cancels (by deleting) the ongoing {@link LocationObject} sharing in the database.
*/ */

View File

@@ -72,13 +72,18 @@ public final class LocationHelper implements LocationListener {
/** /**
* The {@link DatabaseHelper} for db access. * The {@link DatabaseHelper} for db access.
*/ */
private final DatabaseHelper mDbHelper = new DatabaseHelper(); private final DatabaseHelper mDbHelper;
/** /**
* The {@link NotificationHelper} to show and update {@link Notification}s. * The {@link NotificationHelper} to show and update {@link Notification}s.
*/ */
private final NotificationHelper mNotificationHelper; private final NotificationHelper mNotificationHelper;
/**
* The {@link BehaviorSubject} holding the state of current subscription.
*/
private final BehaviorSubject<Boolean> mSharingStateSubject = BehaviorSubject.createDefault(false);
/** /**
* Private C'Tor for singleton instance. * Private C'Tor for singleton instance.
* *
@@ -88,6 +93,7 @@ public final class LocationHelper implements LocationListener {
mLocationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); mLocationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
mContextRef = new WeakReference<>(context); mContextRef = new WeakReference<>(context);
mNotificationHelper = new NotificationHelper(context); mNotificationHelper = new NotificationHelper(context);
mDbHelper = new DatabaseHelper();
mNotificationHelper.createAndRegisterNotificationChannel( mNotificationHelper.createAndRegisterNotificationChannel(
SHARE_LOCATION_CHANNEL_ID, R.string.share_location_notification_channel); SHARE_LOCATION_CHANNEL_ID, R.string.share_location_notification_channel);
} }
@@ -153,6 +159,10 @@ public final class LocationHelper implements LocationListener {
* @return true if subscription started, false otherwise. * @return true if subscription started, false otherwise.
*/ */
public boolean startLocationUpdates(final Activity activity, final int minIntervalMillis, final int minDistance) { public boolean startLocationUpdates(final Activity activity, final int minIntervalMillis, final int minDistance) {
if (mIsSubscribed) {
Log.d(TAG, "startLocationUpdates: Already subscribed, no need to update");
return true;
}
if (ContextCompat.checkSelfPermission(activity, ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(activity, ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
mLocationManager.requestLocationUpdates( mLocationManager.requestLocationUpdates(
LocationManager.FUSED_PROVIDER, LocationManager.FUSED_PROVIDER,
@@ -162,37 +172,44 @@ public final class LocationHelper implements LocationListener {
Looper.getMainLooper() Looper.getMainLooper()
); );
mIsSubscribed = true; mIsSubscribed = true;
return true; } else {
mIsSubscribed = false;
} }
mIsSubscribed = false; mSharingStateSubject.onNext(mIsSubscribed);
return false; return mIsSubscribed;
} }
/** /**
* Stops listening for updates. * Stops listening for updates.
*/ */
public void stopLocationUpdates() { public void stopLocationUpdates() {
if (!mIsSubscribed) {
Log.d(TAG, "stopLocationUpdates: Subscription already ended, no need to end again");
return;
}
mLocationManager.removeUpdates(this); mLocationManager.removeUpdates(this);
mIsSubscribed = false; mIsSubscribed = false;
mSharingStateSubject.onNext(false);
mDbHelper.cancelOngoingLocationSharing(); mDbHelper.cancelOngoingLocationSharing();
mNotificationHelper.cancelNotification(SHARE_LOCATION_NOTIFICATION_ID); mNotificationHelper.cancelNotification(SHARE_LOCATION_NOTIFICATION_ID);
} }
/** /**
* Helper method to toggle the subscription state, if already running, cancel it, if not yet, * Returns the current state of subscription.
* start it.
* *
* @param activity The {@link Activity} from where this was called. * @return true if location sharing is active, false otherwise.
*/ */
public void toggleUpdate(final Activity activity) { public boolean isCurrentlySharing() {
if (mIsSubscribed) { return mIsSubscribed;
Log.d(TAG, "toggleUpdate: Stop subscription"); }
stopLocationUpdates();
} else { /**
Log.d(TAG, "toggleUpdate: Start subscription"); * Returns the {@link BehaviorSubject} holding the state of current subscription.
startLocationUpdates(activity, DEFAULT_MINIMUM_LOCATION_INTERVAL_MILLIS, *
DEFAULT_MINIMUM_LOCATION_INTERVAL_METERS); * @return The {@link BehaviorSubject} holding the state of current subscription.
} */
public BehaviorSubject<Boolean> getSharingStateSubject() {
return mSharingStateSubject;
} }
/** /**

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="800dp"
android:height="800dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.546,20.891L5,20H5L4.546,20.891ZM4.109,20.454L5,20H5L4.109,20.454ZM4.048,16.466L5.032,16.644H5.032L4.048,16.466ZM6.466,14.048L6.644,15.032H6.644L6.466,14.048ZM8.527,14.468L8.114,15.379L8.527,14.468ZM7.598,14.072L7.869,13.11L7.598,14.072ZM6.716,14.017L6.64,13.02L6.716,14.017ZM7.204,14.003L7.278,13.005L7.204,14.003ZM12.165,15.903C12.71,15.812 13.078,15.296 12.986,14.752C12.895,14.207 12.379,13.839 11.835,13.931L12.165,15.903ZM14,22C14.552,22 15,21.552 15,21C15,20.448 14.552,20 14,20V22ZM17.29,17.293C16.899,17.683 16.899,18.317 17.29,18.707C17.681,19.098 18.314,19.098 18.704,18.707L17.29,17.293ZM15.092,14.751C14.954,15.286 15.276,15.831 15.811,15.968C16.346,16.106 16.891,15.784 17.028,15.249L15.092,14.751ZM17.997,20C17.445,20 16.997,20.448 16.997,21C16.997,21.552 17.445,22 17.997,22V20ZM18.007,22C18.559,22 19.007,21.552 19.007,21C19.007,20.448 18.559,20 18.007,20V22ZM14,7C14,8.657 12.657,10 11,10V12C13.761,12 16,9.761 16,7H14ZM11,10C9.343,10 8,8.657 8,7H6C6,9.761 8.239,12 11,12V10ZM8,7C8,5.343 9.343,4 11,4V2C8.239,2 6,4.239 6,7H8ZM11,4C12.657,4 14,5.343 14,7H16C16,4.239 13.761,2 11,2V4ZM11,14C10.263,14 9.566,13.841 8.939,13.557L8.114,15.379C8.995,15.778 9.973,16 11,16V14ZM5,19.4V17.684H3V19.4H5ZM5.6,20C5.303,20 5.141,19.999 5.025,19.99C4.92,19.981 4.942,19.971 5,20L4.092,21.782C4.363,21.92 4.633,21.964 4.862,21.983C5.079,22.001 5.336,22 5.6,22V20ZM3,19.4C3,19.663 2.999,19.921 3.017,20.138C3.036,20.367 3.08,20.637 3.218,20.908L5,20C5.029,20.058 5.019,20.08 5.01,19.975C5.001,19.859 5,19.697 5,19.4H3ZM5,20H5L3.218,20.908C3.41,21.284 3.716,21.59 4.092,21.782L5,20ZM5,17.684C5,17.005 5.004,16.798 5.032,16.644L3.064,16.287C2.996,16.663 3,17.092 3,17.684H5ZM6.64,13.02C6.521,13.029 6.405,13.043 6.287,13.064L6.644,15.032C6.68,15.025 6.726,15.019 6.792,15.014L6.64,13.02ZM5.032,16.644C5.181,15.823 5.823,15.181 6.644,15.032L6.287,13.064C4.646,13.361 3.361,14.646 3.064,16.287L5.032,16.644ZM8.939,13.557C8.469,13.344 8.109,13.177 7.869,13.11L7.326,15.035C7.321,15.033 7.332,15.036 7.37,15.051C7.406,15.065 7.456,15.085 7.524,15.115C7.663,15.175 7.849,15.259 8.114,15.379L8.939,13.557ZM6.792,15.014C7.029,14.996 7.067,14.995 7.129,15L7.278,13.005C7.04,12.988 6.852,13.004 6.64,13.02L6.792,15.014ZM7.869,13.11C7.654,13.049 7.501,13.022 7.278,13.005L7.129,15C7.188,15.004 7.213,15.008 7.229,15.011C7.245,15.013 7.269,15.019 7.326,15.035L7.869,13.11ZM11.835,13.931C11.564,13.976 11.285,14 11,14V16C11.396,16 11.786,15.967 12.165,15.903L11.835,13.931ZM14,20H5.6V22H14V20ZM18.997,15.5C18.997,15.673 18.952,15.805 18.678,16.07C18.524,16.218 18.343,16.365 18.092,16.574C17.854,16.772 17.574,17.009 17.29,17.293L18.704,18.707C18.92,18.491 19.14,18.303 19.371,18.112C19.589,17.931 19.845,17.722 20.066,17.509C20.543,17.049 20.997,16.431 20.997,15.5H18.997ZM17.997,14.5C18.549,14.5 18.997,14.948 18.997,15.5H20.997C20.997,13.843 19.654,12.5 17.997,12.5V14.5ZM17.028,15.249C17.14,14.818 17.532,14.5 17.997,14.5V12.5C16.598,12.5 15.425,13.457 15.092,14.751L17.028,15.249ZM17.997,22H18.007V20H17.997V22Z"
android:fillColor="#000000"/>
</vector>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="75dp"
android:layout_height="75dp"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/active_share_profile_picture"
android:layout_width="50dp"
android:layout_height="50dp"
app:civ_border_width="2dp"
app:civ_border_color="#FF000000"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<TextView
android:id="@+id/active_share_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:gravity="center"
tools:text="Alexander Dörflinger"
app:layout_constraintTop_toBottomOf="@id/active_share_profile_picture"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -6,14 +6,48 @@
android:layout_height="match_parent" android:layout_height="match_parent"
tools:context=".ShareLocationActivity"> tools:context=".ShareLocationActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/active_share_container"
android:layout_width="match_parent"
android:layout_height="75dp"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_no_active_shares"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/share_location_no_sharings_in_progress"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/active_share_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="horizontal"
android:visibility="gone"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/map" android:id="@+id/map"
android:name="com.google.android.gms.maps.SupportMapFragment" android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toBottomOf="@id/active_share_container"
app:layout_constraintBottom_toTopOf="@id/share_location_info_box"/> app:layout_constraintBottom_toTopOf="@id/share_location_info_box"/>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_start_stop_sharing_location"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/map"
app:layout_constraintEnd_toEndOf="@id/map"
android:text="@string/share_location_toggle_button"/>
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/share_location_info_box" android:id="@+id/share_location_info_box"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -23,10 +23,13 @@
<string name="title_share_location">Share Location</string> <string name="title_share_location">Share Location</string>
<string name="share_location_notification_channel">ShareLocation</string> <string name="share_location_notification_channel">ShareLocation</string>
<string name="share_location_notification_content">You are sharing your current location within the family</string> <string name="share_location_notification_content">You are sharing your current location within the family</string>
<string name="share_location_no_sharings_in_progress">Currently nobody is sharing their location</string>
<string name="share_location_info_title_empty">You are currently not following anyone</string>
<string name="share_location_info_title">You are following %s</string> <string name="share_location_info_title">You are following %s</string>
<string name="share_location_info_location">Last Received Location = %f;%f</string> <string name="share_location_info_location">Last Received Location = %f;%f</string>
<string name="share_location_info_altitude">Last received latitude = %.2f Meters</string> <string name="share_location_info_altitude">Last received latitude = %.2f Meters</string>
<string name="share_location_info_speed">The last received velocity was %.2f m/s (%.1f km/h)</string> <string name="share_location_info_speed">The last received velocity was %.2f m/s (%.1f km/h)</string>
<string name="share_location_info_timestamp">This update was received at: %s</string> <string name="share_location_info_timestamp">This update was received at: %s</string>
<string name="share_location_toggle_button">Share your location</string>
</resources> </resources>