diff --git a/ring-android/app/build.gradle b/ring-android/app/build.gradle index 64f2d847b0f6d71f33ce3e2d983ea7891b57065c..23de60015db4fb82c2b96ae476337ebd5b3307a6 100644 --- a/ring-android/app/build.gradle +++ b/ring-android/app/build.gradle @@ -89,6 +89,7 @@ dependencies { implementation "androidx.media:media:$android_support_core_version" implementation "com.google.android.material:material:$material_version" implementation 'com.google.android:flexbox:1.1.1' + implementation 'org.osmdroid:osmdroid-android:6.1.5' // ORM implementation 'com.j256.ormlite:ormlite-android:5.1' diff --git a/ring-android/app/src/main/AndroidManifest.xml b/ring-android/app/src/main/AndroidManifest.xml index 9e0e5561e5e74da54935fe3a46a308f273b687d3..97aacf29da19fbce7b8017a9b72b7714461700f1 100644 --- a/ring-android/app/src/main/AndroidManifest.xml +++ b/ring-android/app/src/main/AndroidManifest.xml @@ -22,6 +22,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. package="cx.ring" android:installLocation="auto"> + <uses-sdk tools:overrideLibrary="com.google.zxing.client.android" /> + <supports-screens android:anyDensity="true" android:largeScreens="true" @@ -46,7 +48,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> - <uses-sdk tools:overrideLibrary="com.google.zxing.client.android" /> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-feature android:name="android.hardware.wifi" @@ -72,6 +74,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. <uses-feature android:name="android.hardware.usb.host" android:required="false" /> + <uses-feature + android:name="android.hardware.location.gps" + android:required="false" /> <application android:allowBackup="false" @@ -182,7 +187,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. android:name=".services.SyncService" android:description="@string/data_transfer_service_description" android:exported="false" - android:foregroundServiceType="dataSync"/> + android:foregroundServiceType="dataSync" /> + <service + android:name=".services.LocationSharingService" + android:foregroundServiceType="location" + android:exported="false" /> <activity android:name=".client.CallActivity" diff --git a/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java b/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java index 16fc2e23e1facaceae36680db26d1c4ea3a7369b..68a3d8cae411ec8d001c96fd57859993cfb42b6a 100644 --- a/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java +++ b/ring-android/app/src/main/java/cx/ring/client/ConversationActivity.java @@ -109,7 +109,7 @@ public class ConversationActivity extends AppCompatActivity implements Colorable .replace(R.id.main_frame, mConversationFragment, null) .commit(); } - if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { + if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action) || Intent.ACTION_VIEW.equals(action)) { mPendingIntent = intent; } } diff --git a/ring-android/app/src/main/java/cx/ring/contacts/AvatarFactory.java b/ring-android/app/src/main/java/cx/ring/contacts/AvatarFactory.java index 2f83e8f3362aafc4a46dd087dddc8d518f01e430..f6cdd0ee45e18cbd72800c59bd5c9f60d8c90c65 100644 --- a/ring-android/app/src/main/java/cx/ring/contacts/AvatarFactory.java +++ b/ring-android/app/src/main/java/cx/ring/contacts/AvatarFactory.java @@ -50,18 +50,25 @@ public class AvatarFactory { getGlideAvatar(view.getContext(), contact).into(view); } - public static Single<Drawable> getAvatar(Context context, CallContact contact) { + public static Single<Drawable> getAvatar(Context context, CallContact contact, boolean presence) { return Single.fromCallable(() -> new AvatarDrawable.Builder() .withContact(contact) .withCircleCrop(true) + .withPresence(presence) .build(context)); } + public static Single<Drawable> getAvatar(Context context, CallContact contact) { + return getAvatar(context, contact, true); + } - public static Single<Bitmap> getBitmapAvatar(Context context, CallContact contact, int size) { - return getAvatar(context, contact) + public static Single<Bitmap> getBitmapAvatar(Context context, CallContact contact, int size, boolean presence) { + return getAvatar(context, contact, presence) .map(d -> drawableToBitmap(d, size)); } + public static Single<Bitmap> getBitmapAvatar(Context context, CallContact contact, int size) { + return getBitmapAvatar(context, contact, size, true); + } public static Single<Bitmap> getBitmapAvatar(Context context, Account account, int size) { return AvatarDrawable.load(context, account) diff --git a/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java b/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java index 421215e779dd7d24051db642e443a7ddab23093a..3df267b2c044fd152b71937e276448213a3253ae 100755 --- a/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java +++ b/ring-android/app/src/main/java/cx/ring/dependencyinjection/JamiInjectionComponent.java @@ -22,6 +22,7 @@ package cx.ring.dependencyinjection; import javax.inject.Singleton; import cx.ring.account.AccountEditionFragment; +import cx.ring.fragments.LocationSharingFragment; import cx.ring.account.AccountWizardActivity; import cx.ring.account.HomeAccountCreationFragment; import cx.ring.account.JamiAccountConnectFragment; @@ -63,6 +64,7 @@ import cx.ring.services.DataTransferService; import cx.ring.services.DeviceRuntimeServiceImpl; import cx.ring.services.HardwareService; import cx.ring.services.HistoryServiceImpl; +import cx.ring.services.LocationSharingService; import cx.ring.services.NotificationServiceImpl; import cx.ring.services.JamiChooserTargetService; import cx.ring.services.SharedPreferencesServiceImpl; @@ -212,11 +214,15 @@ public interface JamiInjectionComponent { void inject(JamiChooserTargetService service); - void inject(ShareWithFragment fragment); + void inject(LocationSharingFragment service); + + void inject(JamiJobService service); - void inject(JamiJobService fragment); + void inject(ShareWithFragment fragment); void inject(ContactDetailsActivity fragment); void inject(IconCardPresenter presenter); + + void inject(LocationSharingService service); } diff --git a/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java index 759d55ab76459be0d63fba183d08103023f3adc3..13f139e8d1f754aa431d06b6669b74836a5ab221 100644 --- a/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java +++ b/ring-android/app/src/main/java/cx/ring/fragments/ConversationFragment.java @@ -20,15 +20,21 @@ package cx.ring.fragments; import android.Manifest; +import android.animation.LayoutTransition; import android.animation.ValueAnimator; +import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; import android.content.ClipData; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.ServiceConnection; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.graphics.Typeface; +import android.content.res.Resources; import android.os.Bundle; +import android.os.IBinder; import android.provider.MediaStore; import android.text.Editable; import android.text.TextUtils; @@ -58,6 +64,8 @@ import androidx.appcompat.view.menu.MenuPopupHelper; import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.Toolbar; import androidx.core.view.ViewCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.DefaultItemAnimator; import java.io.File; @@ -90,11 +98,13 @@ import cx.ring.model.Phone; import cx.ring.model.Error; import cx.ring.model.Uri; import cx.ring.mvp.BaseSupportFragment; +import cx.ring.services.LocationSharingService; import cx.ring.services.NotificationService; import cx.ring.utils.ActionHelper; import cx.ring.utils.AndroidFileUtils; import cx.ring.utils.ContentUriHandler; import cx.ring.utils.DeviceUtils; +import cx.ring.utils.ConversationPath; import cx.ring.utils.MediaButtonsHelper; import cx.ring.views.AvatarDrawable; import io.reactivex.Completable; @@ -107,7 +117,7 @@ import static android.app.Activity.RESULT_OK; public class ConversationFragment extends BaseSupportFragment<ConversationPresenter> implements MediaButtonsHelper.MediaButtonsHelperCallback, ConversationView, SharedPreferences.OnSharedPreferenceChangeListener { - protected static final String TAG = ConversationFragment.class.getSimpleName(); + private static final String TAG = ConversationFragment.class.getSimpleName(); public static final int REQ_ADD_CONTACT = 42; @@ -115,6 +125,7 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen public static final String KEY_ACCOUNT_ID = BuildConfig.APPLICATION_ID + ".ACCOUNT_ID"; public static final String KEY_PREFERENCE_PENDING_MESSAGE = "pendingMessage"; public static final String KEY_PREFERENCE_CONVERSATION_COLOR = "color"; + public static final String EXTRA_SHOW_MAP = "showMap"; private static final int REQUEST_CODE_FILE_PICKER = 1000; private static final int REQUEST_PERMISSION_CAMERA = 1001; @@ -123,13 +134,14 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen private static final int REQUEST_CODE_CAPTURE_AUDIO = 1004; private static final int REQUEST_CODE_CAPTURE_VIDEO = 1005; + private ServiceConnection locationServiceConnection = null; + private FragConversationBinding binding; private MenuItem mAudioCallBtn = null; private MenuItem mVideoCallBtn = null; private View currentBottomView = null; private ConversationAdapter mAdapter = null; - private NumberAdapter mNumberAdapter = null; private int marginPx; private int marginPxTotal; private final ValueAnimator animation = new ValueAnimator(); @@ -142,7 +154,8 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen private int mSelectedPosition; private AvatarDrawable mConversationAvatar; - private Map<String, AvatarDrawable> mParticipantAvatars = new HashMap(); + private Map<String, AvatarDrawable> mParticipantAvatars = new HashMap<>(); + private int mapWidth, mapHeight; public AvatarDrawable getConversationAvatar(String uri) { return mParticipantAvatars.get(uri); @@ -195,15 +208,22 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen } private void updateListPadding() { - if (currentBottomView != null && currentBottomView.getHeight() != 0) + if (currentBottomView != null && currentBottomView.getHeight() != 0) { setBottomPadding(binding.histList, currentBottomView.getHeight() + marginPxTotal); + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); + params.bottomMargin = currentBottomView.getHeight() + marginPxTotal; + binding.mapCard.setLayoutParams(params); + } } @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { injectFragment(((JamiApplication) getActivity().getApplication()).getRingInjectionComponent()); - marginPx = getResources().getDimensionPixelSize(R.dimen.conversation_message_input_margin); + Resources res = getResources(); + marginPx = res.getDimensionPixelSize(R.dimen.conversation_message_input_margin); + mapWidth = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_width); + mapHeight = res.getDimensionPixelSize(R.dimen.location_sharing_minmap_height); marginPxTotal = marginPx; binding = FragConversationBinding.inflate(inflater, container, false); @@ -242,6 +262,14 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen .flatMapCompletable(this::sendFile) .doFinally(contentInfo::releasePermission))); binding.msgInputTxt.setOnEditorActionListener((v, actionId, event) -> actionSendMsgText(actionId)); + binding.msgInputTxt.setOnFocusChangeListener((view, hasFocus) -> { + if (hasFocus) { + Fragment fragment = getChildFragmentManager().findFragmentById(R.id.mapLayout); + if (fragment != null) { + ((LocationSharingFragment) fragment).hideControls(); + } + } + }); binding.msgInputTxt.addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { @@ -320,6 +348,13 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen animation.removeAllUpdateListeners(); binding.histList.setAdapter(null); mCompositeDisposable.clear(); + if (locationServiceConnection != null) { + try { + requireContext().unbindService(locationServiceConnection); + } catch (Exception e) { + Log.w(TAG, "Error unbinding service: " + e.getMessage()); + } + } super.onDestroyView(); } @@ -351,6 +386,7 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen presenter.selectFile(); } + @SuppressLint("RestrictedApi") public void expandMenu(View v) { Context context = requireContext(); PopupMenu popup = new PopupMenu(context, v); @@ -394,6 +430,9 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen case R.id.conv_send_file: selectFile(); break; + case R.id.conv_share_location: + shareLocation(); + break; } return false; }); @@ -402,6 +441,36 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen menuHelper.show(); } + public void shareLocation() { + presenter.shareLocation(); + } + + public void closeLocationSharing(boolean isSharing) { + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); + if (params.width != mapWidth) { + params.width = mapWidth; + params.height = mapHeight; + binding.mapCard.setLayoutParams(params); + } + if (!isSharing) + hideMap(); + } + + public void openLocationSharing() { + binding.conversationLayout.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); + RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) binding.mapCard.getLayoutParams(); + if (params.width != ViewGroup.LayoutParams.MATCH_PARENT) { + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + binding.mapCard.setLayoutParams(params); + } + } + + @Override + public void startShareLocation(String accountId, String conversationId) { + showMap(accountId, conversationId, true); + } + /** * Used to update with the past adapter position when a long click was registered * @param position @@ -410,6 +479,42 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen mSelectedPosition = position; } + @Override + public void showMap(String accountId, String contactId, boolean open) { + if (binding.mapCard.getVisibility() == View.GONE) { + Log.w(TAG, "showMap " + accountId + " " + contactId); + + FragmentManager fragmentManager = getChildFragmentManager(); + LocationSharingFragment fragment = LocationSharingFragment.newInstance(accountId, contactId, open); + fragmentManager.beginTransaction() + .add(R.id.mapLayout, fragment, "map") + .commit(); + binding.mapCard.setVisibility(View.VISIBLE); + } + if (open) { + Fragment fragment = getChildFragmentManager().findFragmentById(R.id.mapLayout); + if (fragment != null) { + ((LocationSharingFragment) fragment).showControls(); + } + } + } + + @Override + public void hideMap() { + if (binding.mapCard.getVisibility() != View.GONE) { + binding.mapCard.setVisibility(View.GONE); + + FragmentManager fragmentManager = getChildFragmentManager(); + Fragment fragment = fragmentManager.findFragmentById(R.id.mapLayout); + + if (fragment != null) { + fragmentManager.beginTransaction() + .remove(fragment) + .commit(); + } + } + } + public void takePicture() { if (!presenter.getDeviceRuntimeService().hasVideoPermission()) { requestPermissions(new String[]{Manifest.permission.CAMERA}, JamiApplication.PERMISSIONS_REQUEST); @@ -692,6 +797,35 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen } catch (Exception e) { Log.e(TAG, "Can't load conversation preferences"); } + + if (locationServiceConnection == null) { + locationServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + Log.w(TAG, "onServiceConnected"); + LocationSharingService.LocalBinder binder = (LocationSharingService.LocalBinder) service; + LocationSharingService locationService = binder.getService(); + ConversationPath path = new ConversationPath(presenter.getPath()); + if (locationService.isSharing(path)) { + showMap(accountId, contactUri.getUri(), false); + } + try { + requireContext().unbindService(locationServiceConnection); + } catch (Exception e) { + Log.w(TAG, "Error unbinding service", e); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + Log.w(TAG, "onServiceDisconnected"); + locationServiceConnection = null; + } + }; + + Log.w(TAG, "bindService"); + requireContext().bindService(new Intent(requireContext(), LocationSharingService.class), locationServiceConnection, 0); + } } @Override @@ -733,10 +867,8 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen @Override public void displayNumberSpinner(final Conversation conversation, final Uri number) { binding.numberSelector.setVisibility(View.VISIBLE); - mNumberAdapter = new NumberAdapter(getActivity(), - conversation.getContact(), - false); - binding.numberSelector.setAdapter(mNumberAdapter); + binding.numberSelector.setAdapter(new NumberAdapter(getActivity(), + conversation.getContact(), false)); binding.numberSelector.setSelection(getIndex(binding.numberSelector, number)); } @@ -863,7 +995,7 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen } @Override - public void onPrepareOptionsMenu(Menu menu) { + public void onPrepareOptionsMenu(@NonNull Menu menu) { super.onPrepareOptionsMenu(menu); boolean visible = binding.cvMessageInput.getVisibility() == View.VISIBLE; if (mAudioCallBtn != null) @@ -935,21 +1067,31 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen } public void handleShareIntent(Intent intent) { - String type = intent.getType(); - if (type == null) { - Log.w(TAG, "Can't share with no type"); - return; - } - if (type.startsWith("text/plain")) { - binding.msgInputTxt.setText(intent.getStringExtra(Intent.EXTRA_TEXT)); - } else { - android.net.Uri uri = intent.getData(); - ClipData clip = intent.getClipData(); - if (uri == null && clip != null && clip.getItemCount() > 0) - uri = clip.getItemAt(0).getUri(); - if (uri == null) + Log.w(TAG, "handleShareIntent " + intent); + + String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)) { + String type = intent.getType(); + if (type == null) { + Log.w(TAG, "Can't share with no type"); return; - startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri).flatMapCompletable(this::sendFile)); + } + if (type.startsWith("text/plain")) { + binding.msgInputTxt.setText(intent.getStringExtra(Intent.EXTRA_TEXT)); + } else { + android.net.Uri uri = intent.getData(); + ClipData clip = intent.getClipData(); + if (uri == null && clip != null && clip.getItemCount() > 0) + uri = clip.getItemAt(0).getUri(); + if (uri == null) + return; + startFileSend(AndroidFileUtils.getCacheFile(requireContext(), uri).flatMapCompletable(this::sendFile)); + } + } else if (Intent.ACTION_VIEW.equals(action)) { + ConversationPath path = ConversationPath.fromIntent(intent); + if (path != null && intent.getBooleanExtra(EXTRA_SHOW_MAP, false)) { + shareLocation(); + } } } @@ -967,7 +1109,6 @@ public class ConversationFragment extends BaseSupportFragment<ConversationPresen //Use Android Storage File Access to download the file Intent downloadFileIntent = new Intent(Intent.ACTION_CREATE_DOCUMENT); downloadFileIntent.setType(AndroidFileUtils.getMimeTypeFromExtension(file.getExtension())); - downloadFileIntent.addCategory(Intent.CATEGORY_OPENABLE); downloadFileIntent.putExtra(Intent.EXTRA_TITLE,file.getDisplayName()); diff --git a/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.java b/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.java new file mode 100644 index 0000000000000000000000000000000000000000..b34d0bb5cc85a60b0da1dd7ea5689dc104756756 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/fragments/LocationSharingFragment.java @@ -0,0 +1,617 @@ +/* + * Copyright (C) 2004-2020 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.fragments; + +import android.Manifest; +import android.animation.LayoutTransition; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.graphics.drawable.BitmapDrawable; +import android.icu.text.MeasureFormat; +import android.icu.util.Measure; +import android.icu.util.MeasureUnit; +import android.location.Location; +import android.os.Build; +import android.os.Bundle; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceManager; + +import android.os.IBinder; +import android.text.format.DateUtils; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; + +import org.osmdroid.config.Configuration; +import org.osmdroid.config.IConfigurationProvider; +import org.osmdroid.tileprovider.tilesource.TileSourceFactory; +import org.osmdroid.util.BoundingBox; +import org.osmdroid.util.GeoPoint; +import org.osmdroid.views.CustomZoomButtonsController; +import org.osmdroid.views.MapView; +import org.osmdroid.views.overlay.Marker; +import org.osmdroid.views.overlay.mylocation.IMyLocationConsumer; +import org.osmdroid.views.overlay.mylocation.IMyLocationProvider; +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.inject.Inject; + +import cx.ring.R; +import cx.ring.application.JamiApplication; +import cx.ring.contacts.AvatarFactory; +import cx.ring.facades.ConversationFacade; +import cx.ring.model.Account; +import cx.ring.model.CallContact; +import cx.ring.model.Uri; +import cx.ring.services.LocationSharingService; +import cx.ring.utils.ConversationPath; +import cx.ring.utils.TouchClickListener; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.subjects.BehaviorSubject; +import io.reactivex.subjects.Subject; + +public class LocationSharingFragment extends Fragment { + private static final String TAG = LocationSharingFragment.class.getSimpleName(); + private static final int REQUEST_CODE_LOCATION = 47892; + private static final String KEY_SHOW_CONTROLS = "showControls"; + + private final CompositeDisposable mDisposableBag = new CompositeDisposable(); + private final CompositeDisposable mServiceDisposableBag = new CompositeDisposable(); + + enum MapState {NONE, MINI, FULL} + + @Inject + ConversationFacade mConversationFacade; + + private ConversationPath mPath; + private CallContact mContact; + + private final Subject<Boolean> mShowControlsSubject = BehaviorSubject.create(); + private final Subject<Boolean> mIsSharingSubject = BehaviorSubject.create(); + private final Subject<Boolean> mIsContactSharingSubject = BehaviorSubject.create(); + private final Observable<MapState> mShowMapSubject = Observable.combineLatest( + mShowControlsSubject, + mIsSharingSubject, + mIsContactSharingSubject, + (showControls, isSharing, isContactSharing) -> showControls + ? MapState.FULL + : ((isSharing || isContactSharing) ? MapState.MINI : MapState.NONE)) + .distinctUntilChanged(); + + private int bubbleSize; + + private Toolbar mToolbar; + private ViewGroup mSnippetGroup; + private TextView mSnippet; + private ImageView mSnippetShadow; + private ViewGroup mShareControls; + private ViewGroup mShareControlsMini; + private ExtendedFloatingActionButton mShareButton; + private Chip mStopShareButton; + private ChipGroup mShareTimeGroup; + private Chip mTimeRemaining; + private MapView mMap = null; + private MyLocationNewOverlay overlay; + private Marker marker; + private BoundingBox lastBoundingBox = null; + private boolean trackAll = true; + private Integer mStartSharingPending = null; + + private LocationSharingService mService = null; + private boolean mBound = false; + + public LocationSharingFragment() { + super(R.layout.frag_location_sharing); + } + + public static LocationSharingFragment newInstance(String accountId, String conversationId, boolean showControls) { + LocationSharingFragment fragment = new LocationSharingFragment(); + Bundle args = ConversationPath.toBundle(accountId, conversationId); + args.putBoolean(KEY_SHOW_CONTROLS, showControls); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + ((JamiApplication) requireActivity().getApplication()).getRingInjectionComponent().inject(this); + setRetainInstance(true); + + Bundle args = getArguments(); + if (args != null) { + mPath = ConversationPath.fromBundle(args); + mShowControlsSubject.onNext(args.getBoolean(KEY_SHOW_CONTROLS, true)); + } + + Context ctx = requireContext(); + IConfigurationProvider configuration = Configuration.getInstance(); + configuration.load(ctx, PreferenceManager.getDefaultSharedPreferences(ctx)); + configuration.setOsmdroidBasePath(new File(ctx.getCacheDir(), "osm")); + configuration.setMapViewHardwareAccelerated(true); + + bubbleSize = ctx.getResources().getDimensionPixelSize(R.dimen.location_sharing_avatar_size); + } + + @RequiresApi(api = Build.VERSION_CODES.N) + private static CharSequence formatDuration(long millis, MeasureFormat.FormatWidth width) { + final MeasureFormat formatter = MeasureFormat.getInstance(Locale.getDefault(), width); + if (millis >= DateUtils.HOUR_IN_MILLIS) { + final int hours = (int) ((millis + DateUtils.HOUR_IN_MILLIS/2) / DateUtils.HOUR_IN_MILLIS); + return formatter.format(new Measure(hours, MeasureUnit.HOUR)); + } else if (millis >= DateUtils.MINUTE_IN_MILLIS) { + final int minutes = (int) ((millis + DateUtils.MINUTE_IN_MILLIS/2) / DateUtils.MINUTE_IN_MILLIS); + return formatter.format(new Measure(minutes, MeasureUnit.MINUTE)); + } else { + final int seconds = (int) ((millis + DateUtils.SECOND_IN_MILLIS/2) / DateUtils.SECOND_IN_MILLIS); + return formatter.format(new Measure(seconds, MeasureUnit.SECOND)); + } + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mToolbar = view.findViewById(R.id.locshare_toolbar); + mSnippetGroup = view.findViewById(R.id.locshare_snipet); + mSnippet = view.findViewById(R.id.locshare_snipet_txt); + mSnippetShadow = view.findViewById(R.id.locshare_snipet_txt_shadow); + mMap = view.findViewById(R.id.map); + mShareControls = view.findViewById(R.id.shareControls); + mShareControlsMini = view.findViewById(R.id.shareControlsMini); + mShareButton = view.findViewById(R.id.btn_share_location); + mStopShareButton = view.findViewById(R.id.location_share_stop); + mTimeRemaining = view.findViewById(R.id.location_share_time_remaining); + mShareTimeGroup = view.findViewById(R.id.location_share_time_group); + Chip chip_1h = view.findViewById(R.id.location_share_time_1h); + Chip chip_10m = view.findViewById(R.id.location_share_time_10m); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + chip_1h.setText(formatDuration( DateUtils.HOUR_IN_MILLIS, MeasureFormat.FormatWidth.WIDE)); + chip_10m.setText(formatDuration( 10 * DateUtils.MINUTE_IN_MILLIS, MeasureFormat.FormatWidth.WIDE)); + } + + View locateView = view.findViewById(R.id.btn_center_position); + locateView.setOnClickListener(v -> { + if (overlay != null) { + trackAll = true; + if (lastBoundingBox != null) + mMap.zoomToBoundingBox(lastBoundingBox, true); + else + overlay.enableFollowLocation(); + } + }); + mShareTimeGroup.setOnCheckedChangeListener((group, id) -> { + if (id == View.NO_ID) + group.check(R.id.location_share_time_1h); + }); + mToolbar.setNavigationOnClickListener(v -> mShowControlsSubject.onNext(false)); + mStopShareButton.setOnClickListener(v -> stopSharing()); + + mMap.setTileSource(TileSourceFactory.MAPNIK); + mMap.setHorizontalMapRepetitionEnabled(false); + mMap.setTilesScaledToDpi(true); + mMap.setMapOrientation(0, false); + mMap.setMinZoomLevel(1d); + mMap.setMaxZoomLevel(19.d); + mMap.getZoomController().setVisibility(CustomZoomButtonsController.Visibility.NEVER); + mMap.getController().setZoom(14.0); + ((ViewGroup)view).getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); + } + + private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) { + @Override + public void handleOnBackPressed() { + mShowControlsSubject.onNext(false); + } + }; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + requireActivity().getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mShowControlsSubject.onComplete(); + mIsSharingSubject.onComplete(); + mIsContactSharingSubject.onComplete(); + } + + public void onResume() { + super.onResume(); + mMap.onResume(); + if (overlay != null) + overlay.enableMyLocation(); + } + + public void onPause(){ + super.onPause(); + mMap.onPause(); + if (overlay != null) + overlay.disableMyLocation(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode == REQUEST_CODE_LOCATION) { + boolean granted = false; + for (int result : grantResults) + granted |= (result == PackageManager.PERMISSION_GRANTED); + if (granted) { + startService(); + } else { + mIsSharingSubject.onNext(false); + mShowControlsSubject.onNext(false); + } + } + } + + @Override + public void onStart() { + super.onStart(); + mDisposableBag.add(mServiceDisposableBag); + mDisposableBag.add(mShowControlsSubject.subscribe(this::setShowControls)); + mDisposableBag.add(mIsSharingSubject.subscribe(this::setIsSharing)); + mDisposableBag.add(mShowMapSubject.subscribe(state -> { + Fragment p = getParentFragment(); + if (p instanceof ConversationFragment) { + ConversationFragment parent = (ConversationFragment) p; + if (state == MapState.FULL) + parent.openLocationSharing(); + else + parent.closeLocationSharing(state == MapState.MINI); + } + })); + mDisposableBag.add(mIsContactSharingSubject + .distinctUntilChanged() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(sharing -> { + if (sharing) { + String sharingString = getString(R.string.location_share_contact, mContact.getDisplayName()); + mToolbar.setSubtitle(sharingString); + mSnippet.setVisibility(View.VISIBLE); + mSnippetShadow.setVisibility(View.VISIBLE); + mSnippet.setText(sharingString); + } else { + mToolbar.setSubtitle(null); + mSnippet.setVisibility(View.GONE); + mSnippetShadow.setVisibility(View.GONE); + mSnippet.setText(null); + } + })); + + final Uri contactUri = new Uri(mPath.getContactId()); + + mDisposableBag.add(mConversationFacade + .getAccountSubject(mPath.getAccountId()) + .flatMapObservable(account -> account.getLocationsUpdates() + .map(locations -> { + List<Observable<LocationViewModel>> r = new ArrayList<>(locations.size()); + boolean isContactSharing = false; + for (Map.Entry<CallContact, Observable<Account.ContactLocation>> l : locations.entrySet()) { + if (l.getKey() == account.getContactFromCache(contactUri)) { + isContactSharing = true; + mContact = l.getKey(); + } + r.add(l.getValue().map(cl -> new LocationViewModel(l.getKey(), cl))); + } + mIsContactSharingSubject.onNext(isContactSharing); + return r; + })) + .flatMap(locations -> Observable.combineLatest(locations, locsArray -> { + List<LocationViewModel> list = new ArrayList<>(locsArray.length); + for (Object vm : locsArray) + list.add((LocationViewModel)vm); + return list; + })) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(locations -> { + Context context = getContext(); + if (context != null) { + mMap.getOverlays().clear(); + if (overlay != null) + mMap.getOverlays().add(overlay); + if (marker != null) + mMap.getOverlays().add(marker); + + List<GeoPoint> geoLocations =new ArrayList<>(locations.size() + 1); + GeoPoint myLoc = overlay == null ? null : overlay.getMyLocation(); + if (myLoc != null) { + geoLocations.add(myLoc); + } + + for (LocationViewModel vm : locations) { + Marker m = new Marker(mMap); + GeoPoint position = new GeoPoint(vm.location.latitude, vm.location.longitude); + m.setInfoWindow(null); + m.setPosition(position); + m.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER); + geoLocations.add(position); + mDisposableBag.add(AvatarFactory.getBitmapAvatar(context, vm.contact, bubbleSize, false).subscribe(avatar -> { + BitmapDrawable bd = new BitmapDrawable(context.getResources(), avatar); + m.setIcon(bd); + m.setInfoWindow(null); + mMap.getOverlays().add(m); + })); + } + + if (trackAll) { + if (geoLocations.size() == 1) { + lastBoundingBox = null; + mMap.getController().animateTo(geoLocations.get(0)); + } else { + BoundingBox bb = BoundingBox.fromGeoPointsSafe(geoLocations); + bb = bb.increaseByScale(1.5f); + lastBoundingBox = bb; + mMap.zoomToBoundingBox(bb, true); + } + } + } + }, e -> Log.w(TAG, "Error updating contact position", e)) + ); + + Context ctx = requireContext(); + if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + mIsSharingSubject.onNext(false); + mDisposableBag.add(mShowControlsSubject + .firstElement() + .subscribe(showControls -> { + if (showControls) { + requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE_LOCATION); + } + })); + } else { + startService(); + } + } + + @Override + public void onStop() { + super.onStop(); + if (mBound) { + mServiceDisposableBag.clear(); + requireContext().unbindService(mConnection); + mBound = false; + } + mDisposableBag.clear(); + } + + private void startService() { + Context ctx = requireContext(); + ctx.bindService(new Intent(ctx, LocationSharingService.class), mConnection, Context.BIND_AUTO_CREATE); + } + + + void showControls() { + mShowControlsSubject.onNext(true); + } + + void hideControls() { + mShowControlsSubject.onNext(false); + } + + private void setShowControls(boolean show) { + if (show) { + onBackPressedCallback.setEnabled(true); + mSnippetGroup.setVisibility(View.GONE); + mShareControlsMini.setVisibility(View.GONE); + mShareControlsMini.postDelayed(() -> { + mShareControlsMini.setVisibility(View.GONE); + mSnippetGroup.setVisibility(View.GONE); + }, 300); + mShareControls.setVisibility(View.VISIBLE); + mToolbar.setVisibility(View.VISIBLE); + mMap.setOnTouchListener(null); + mMap.setMultiTouchControls(true); + } else { + onBackPressedCallback.setEnabled(false); + mShareControls.setVisibility(View.GONE); + mShareControlsMini.postDelayed(() -> { + mShareControlsMini.setVisibility(View.VISIBLE); + mSnippetGroup.setVisibility(View.VISIBLE); + }, 300); + mToolbar.setVisibility(View.GONE); + mMap.setMultiTouchControls(false); + mMap.setOnTouchListener(new TouchClickListener(mMap.getContext(), v -> mShowControlsSubject.onNext(true))); + } + } + + static class RxLocationListener implements IMyLocationProvider { + private final CompositeDisposable mDisposableBag = new CompositeDisposable(); + private Observable<Location> mLocation; + + RxLocationListener(Observable<Location> location) { + mLocation = location; + } + + @Override + public boolean startLocationProvider(IMyLocationConsumer myLocationConsumer) { + mDisposableBag.add(mLocation.subscribe(loc -> myLocationConsumer.onLocationChanged(loc, this))); + return false; + } + + @Override + public void stopLocationProvider() { + mDisposableBag.clear(); + } + + @Override + public Location getLastKnownLocation() { + return mLocation.blockingFirst(); + } + + @Override + public void destroy() { + mDisposableBag.dispose(); + mLocation = null; + } + } + + static class LocationViewModel { + CallContact contact; + Account.ContactLocation location; + LocationViewModel(CallContact c, Account.ContactLocation cl) { + contact = c; + location = cl; + } + } + + private ServiceConnection mConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + Log.w(TAG, "onServiceConnected"); + LocationSharingService.LocalBinder binder = (LocationSharingService.LocalBinder) service; + mService = binder.getService(); + mBound = true; + + if (marker == null) { + marker = new Marker(mMap); + marker.setInfoWindow(null); + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER); + mServiceDisposableBag.add(mConversationFacade + .getAccountSubject(mPath.getAccountId()) + .flatMap(account -> AvatarFactory.getBitmapAvatar(requireContext(), account, bubbleSize)) + .subscribe(avatar -> { + marker.setIcon(new BitmapDrawable(requireContext().getResources(), avatar)); + mMap.getOverlays().add(marker); + })); + } + + mServiceDisposableBag.add(mService.getContactSharing() + .subscribe(location -> mIsSharingSubject.onNext(location.contains(mPath)))); + mServiceDisposableBag.add(mService.getMyLocation() + .subscribe(location -> marker.setPosition(new GeoPoint(location)))); + mServiceDisposableBag.add(mService.getMyLocation() + .firstElement() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(location -> { + // start map on first location + mMap.setExpectedCenter(new GeoPoint(location)); + overlay = new MyLocationNewOverlay(new RxLocationListener(mService.getMyLocation()), mMap); + overlay.enableMyLocation(); + mMap.getOverlays().add(overlay); + })); + + if (mStartSharingPending != null) { + Integer pending = mStartSharingPending; + mStartSharingPending = null; + startSharing(pending); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mBound = false; + mService = null; + mServiceDisposableBag.clear(); + } + }; + + private int getSelectedDuration() { + switch (mShareTimeGroup.getCheckedChipId()) { + case R.id.location_share_time_10m: + return 10 * 60; + case R.id.location_share_time_1h: + default: + return 60 * 60; + } + } + + private void setIsSharing(boolean sharing) { + if (sharing) { + mShareButton.setBackgroundColor(ContextCompat.getColor(mShareButton.getContext(), R.color.design_default_color_error)); + mShareButton.setText(R.string.location_share_action_stop); + mShareButton.setOnClickListener(v -> stopSharing()); + mShareTimeGroup.setVisibility(View.GONE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + mTimeRemaining.setVisibility(View.VISIBLE); + mDisposableBag.add(mService.getContactSharingExpiration(mPath) + .subscribe(l -> mTimeRemaining.setText(formatDuration(l, MeasureFormat.FormatWidth.SHORT)))); + } + mStopShareButton.setVisibility(View.VISIBLE); + requireView().post(this::hideControls); + } else { + mShareButton.setBackgroundColor(ContextCompat.getColor(mShareButton.getContext(), R.color.colorSecondary)); + mShareButton.setText(R.string.location_share_action_start); + mShareButton.setOnClickListener(v -> startSharing(getSelectedDuration())); + mTimeRemaining.setVisibility(View.GONE); + mShareTimeGroup.setVisibility(View.VISIBLE); + mStopShareButton.setVisibility(View.GONE); + } + } + + private void startSharing(int durationSec) { + Context ctx = requireContext(); + try { + if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED + && ActivityCompat.checkSelfPermission(ctx, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + mStartSharingPending = durationSec; + requestPermissions(new String[]{ Manifest.permission.ACCESS_FINE_LOCATION }, REQUEST_CODE_LOCATION); + } else { + Intent intent = new Intent(LocationSharingService.ACTION_START, mPath.toUri(), ctx, LocationSharingService.class); + intent.putExtra(LocationSharingService.EXTRA_SHARING_DURATION, durationSec); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(intent); + } else { + ctx.startService(intent); + } + } + } catch (Exception e) { + Toast.makeText(ctx, "Error starting location sharing: " + e.getLocalizedMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void stopSharing() { + Context ctx = requireContext(); + try { + Intent intent = new Intent(LocationSharingService.ACTION_STOP, mPath.toUri(), ctx, LocationSharingService.class); + ctx.startService(intent); + } catch (Exception e) { + Log.w(TAG, "Error stopping location sharing", e); + } + } +} diff --git a/ring-android/app/src/main/java/cx/ring/services/LocationSharingService.java b/ring-android/app/src/main/java/cx/ring/services/LocationSharingService.java new file mode 100644 index 0000000000000000000000000000000000000000..f504f81e89dffdef513f0bc3b89bffb1944671b6 --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/services/LocationSharingService.java @@ -0,0 +1,387 @@ +/* + * Copyright (C) 2004-2020 Savoir-faire Linux Inc. + * + * Authors: Adrien Béraud <adrien.beraud@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +package cx.ring.services; + +import android.Manifest; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.location.Criteria; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Binder; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.SystemClock; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; + +import org.json.JSONObject; + +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import cx.ring.R; +import cx.ring.application.JamiApplication; +import cx.ring.client.ConversationActivity; +import cx.ring.daemon.Blob; +import cx.ring.daemon.Ringservice; +import cx.ring.daemon.StringMap; +import cx.ring.facades.ConversationFacade; +import cx.ring.fragments.ConversationFragment; +import cx.ring.model.Uri; +import cx.ring.utils.ConversationPath; +import io.reactivex.Observable; +import io.reactivex.ObservableSource; +import io.reactivex.Observer; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.BehaviorSubject; +import io.reactivex.subjects.Subject; + +public class LocationSharingService extends Service implements LocationListener { + private static final String TAG = "LocationSharingService"; + + public static final int NOTIF_SYNC_SERVICE_ID = 931801; + + public static final String ACTION_START = "startSharing"; + public static final String ACTION_STOP = "stopSharing"; + public static final String EXTRA_SHARING_DURATION = "locationShareDuration"; + + public static final String PREFERENCES_LOCATION = "location"; + public static final String PREFERENCES_KEY_POS_LONG = "lastPosLongitude"; + public static final String PREFERENCES_KEY_POS_LAT = "lastPosLatitude"; + public static final int SHARE_DURATION_SEC = 60 * 5; + + @Inject + ConversationFacade mConversationFacade; + + private final Random mRandom = new Random(); + private final IBinder binder = new LocalBinder(); + private boolean started = false; + + private LocationManager mLocationManager; + private NotificationManager mNoticationManager; + private SharedPreferences mPreferences; + private Handler mHandler; + + private final Subject<Location> mMyLocationSubject = BehaviorSubject.create(); + private final Map<ConversationPath, Date> contactLocationShare = new HashMap<>(); + private final Subject<Set<ConversationPath>> mContactSharingSubject = BehaviorSubject.createDefault(contactLocationShare.keySet()); + + private final CompositeDisposable mDisposableBag = new CompositeDisposable(); + + public LocationSharingService() { + } + + public Observable<Location> getMyLocation() { + return mMyLocationSubject; + } + + public Observable<Set<ConversationPath>> getContactSharing() { + return mContactSharingSubject; + } + + public Observable<Long> getContactSharingExpiration(ConversationPath path) { + return Observable.timer(1, TimeUnit.SECONDS, AndroidSchedulers.mainThread()) + .startWith(0L) + .repeat() + .map(i -> contactLocationShare.get(path).getTime() - SystemClock.elapsedRealtime()) + .onErrorResumeNext((ObservableSource<Long>) Observer::onComplete); + } + + @Override + public void onCreate() { + ((JamiApplication) getApplication()).getRingInjectionComponent().inject(this); + + mLocationManager = (LocationManager) getSystemService(Context.LOCATION_SERVICE); + mNoticationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + mPreferences = getSharedPreferences(PREFERENCES_LOCATION, Context.MODE_PRIVATE); + mHandler = new Handler(getMainLooper()); + String posLongitude = mPreferences.getString(PREFERENCES_KEY_POS_LONG, null); + String posLatitude = mPreferences.getString(PREFERENCES_KEY_POS_LAT, null); + if (posLatitude != null && posLongitude != null) { + try { + Location location = new Location("cache"); + location.setLatitude(Double.parseDouble(posLatitude)); + location.setLongitude(Double.parseDouble(posLongitude)); + mMyLocationSubject.onNext(location); + } catch (Exception e) { + Log.w(TAG, "Can't load last location", e); + } + } + if (mLocationManager != null) { + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + || ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED) { + try { + Criteria c = new Criteria(); + c.setAccuracy(Criteria.ACCURACY_FINE); + mLocationManager.requestLocationUpdates(0, 0.f, c, this, null); + } catch (Exception e) { + Log.e(TAG, "Can't start location tracking", e); + } + } + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.w(TAG, "onStartCommand " + intent); + String action = intent.getAction(); + ConversationPath path = ConversationPath.fromIntent(intent); + long now = SystemClock.elapsedRealtime(); + + if (ACTION_START.equals(action)) { + int duration = intent.getIntExtra(EXTRA_SHARING_DURATION, SHARE_DURATION_SEC); + long expiration = now + (duration * 1000L); + if (contactLocationShare.put(path, new Date(expiration)) == null) { + mContactSharingSubject.onNext(contactLocationShare.keySet()); + } + mHandler.postAtTime(this::refreshSharing, expiration); + + if (!started) { + started = true; + mDisposableBag.add(getNotification(now) + .subscribe(notification -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + startForeground(NOTIF_SYNC_SERVICE_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION); + else + startForeground(NOTIF_SYNC_SERVICE_ID, notification); + + mHandler.postAtTime(this::refreshNotificationTimer, now + 30 * 1000); + JamiApplication.getInstance().startDaemon(); + })); + mDisposableBag.add(mMyLocationSubject + .throttleLatest(10, TimeUnit.SECONDS) + .map(location -> { + JSONObject out = new JSONObject(); + out.put("lat", location.getLatitude()); + out.put("long", location.getLongitude()); + out.put("alt", location.getAltitude()); + out.put("time",location.getElapsedRealtimeNanos()/1000000L); + float bearing = location.getBearing(); + if (bearing != 0.f) + out.put("bearing", bearing); + float speed = location.getSpeed(); + if (speed != 0.f) + out.put("speed", speed); + return out; + }) + .subscribe(location -> { + Log.w(TAG, "location send " + location + " to " + contactLocationShare.size()); + StringMap msgs = new StringMap(); + msgs.setRaw(CallService.MIME_GEOLOCATION, Blob.fromString(location.toString())); + for (ConversationPath p : contactLocationShare.keySet()) { + Ringservice.sendAccountTextMessage(p.getAccountId(), p.getContactId(), msgs); + } + })); + } else { + mDisposableBag.add(getNotification(now) + .subscribe(notification -> mNoticationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); + } + } + else if (ACTION_STOP.equals(action)) { + if (path == null) + contactLocationShare.clear(); + else + contactLocationShare.remove(path); + + mContactSharingSubject.onNext(contactLocationShare.keySet()); + + if (contactLocationShare.isEmpty()) { + Log.w(TAG, "stopping sharing " + intent); + mDisposableBag.clear(); + stopForeground(true); + stopSelf(); + started = false; + } else { + mDisposableBag.add(getNotification(now) + .subscribe(notification -> mNoticationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); + } + } + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + Log.w(TAG, "onDestroy"); + if (mLocationManager != null) { + mLocationManager.removeUpdates(this); + } + mMyLocationSubject.onComplete(); + mContactSharingSubject.onComplete(); + mDisposableBag.dispose(); + } + + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public boolean onUnbind(Intent intent) { + return true; + } + + @Override + public void onLocationChanged(Location location) { + // Log.w(TAG, "onLocationChanged " + location.toString()); + mMyLocationSubject.onNext(location); + mPreferences.edit() + .putString(PREFERENCES_KEY_POS_LAT, Double.toString(location.getLatitude())) + .putString(PREFERENCES_KEY_POS_LONG, Double.toString(location.getLongitude())) + .apply(); + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) {} + + @Override + public void onProviderEnabled(String provider) { + } + + @Override + public void onProviderDisabled(String provider) { + } + + @NonNull + private Single<Notification> getNotification(long now) { + int contactCount = contactLocationShare.size(); + ConversationPath firsPath = contactLocationShare.keySet().iterator().next(); + Date largest = null; + for (Date d : contactLocationShare.values()) + if (largest == null || d.after(largest)) + largest = d; + final long largestDate = largest == null ? now : largest.getTime(); + // Log.w(TAG, "getNotification " + firsPath.getContactId()); + + return mConversationFacade.getAccountSubject(firsPath.getAccountId()) + .map(account -> account.getContactFromCache(new Uri(firsPath.getContactId()))) + .map(contact -> { + String title; + final Intent stopIntent = new Intent(ACTION_STOP).setClass(getApplicationContext(), LocationSharingService.class); + final Intent contentIntent = new Intent(Intent.ACTION_VIEW, firsPath.toUri(), getApplicationContext(), ConversationActivity.class) + .putExtra(ConversationFragment.EXTRA_SHOW_MAP, true) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (contactCount == 1) { + stopIntent.setData(firsPath.toUri()); + title = getString(R.string.notif_location_title, contact.getDisplayName()); + } else { + title = getString(R.string.notif_location_multi_title, contactCount); + } + String subtitle = getString(R.string.notif_location_remaining, (int)Math.ceil((largestDate - now)/(double)(1000 * 60))); + + return new NotificationCompat.Builder(this, NotificationServiceImpl.NOTIF_CHANNEL_SYNC) + .setContentTitle(title) + .setContentText(subtitle) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) + .setAutoCancel(false) + .setOngoing(false) + .setVibrate(null) + .setColorized(true) + .setColor(getResources().getColor(R.color.color_primary_dark)) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setOnlyAlertOnce(true) + .setDeleteIntent(PendingIntent.getService(getApplicationContext(), mRandom.nextInt(), stopIntent, 0)) + .setContentIntent(PendingIntent.getActivity(getApplicationContext(), mRandom.nextInt(), contentIntent, 0)) + .addAction(R.drawable.baseline_location_disabled_24, + getText(R.string.notif_location_action_stop), + PendingIntent.getService( + getApplicationContext(), + 0, + stopIntent, + PendingIntent.FLAG_ONE_SHOT)) + .build(); + }) + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()); + } + + private void refreshSharing() { + if (!started) + return; + + boolean changed = false; + final Date now = new Date(SystemClock.uptimeMillis()); + Iterator<Map.Entry<ConversationPath, Date>> it = contactLocationShare.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry<ConversationPath, Date> e = it.next(); + if (e.getValue().before(now)) { + changed = true; + it.remove(); + } + } + + if (changed) + mContactSharingSubject.onNext(contactLocationShare.keySet()); + + if (contactLocationShare.isEmpty()) { + mDisposableBag.clear(); + stopForeground(true); + stopSelf(); + started = false; + } else if (changed) { + mDisposableBag.add(getNotification(now.getTime()) + .subscribe(notification -> mNoticationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); + } + } + + private void refreshNotificationTimer() { + if (!started) + return; + long now = SystemClock.uptimeMillis(); + mDisposableBag.add(getNotification(now) + .subscribe(notification -> mNoticationManager.notify(NOTIF_SYNC_SERVICE_ID, notification))); + mHandler.postAtTime(this::refreshNotificationTimer, now + (30 * 1000)); + } + + public boolean isSharing(ConversationPath path) { + return contactLocationShare.get(path) != null; + } + + public class LocalBinder extends Binder { + public LocationSharingService getService() { + return LocationSharingService.this; + } + } +} diff --git a/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java b/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java index e8bf9d814c2c50fed16285df5eafe94cf42738c9..f8b15f736a07af1d748d05f5c31469d2edad84a9 100644 --- a/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java +++ b/ring-android/app/src/main/java/cx/ring/services/NotificationServiceImpl.java @@ -54,6 +54,7 @@ import java.io.File; import java.util.Collection; import java.util.Iterator; import java.util.LinkedHashMap; +import java.util.Objects; import java.util.Random; import java.util.Set; import java.util.TreeMap; @@ -327,6 +328,32 @@ public class NotificationServiceImpl implements NotificationService { return messageNotificationBuilder.build(); } + @Override + public void showLocationNotification(Account first, CallContact contact) { + android.net.Uri path = ConversationPath.toUri(first.getAccountID(), contact.getPrimaryUri()); + + Intent intentConversation = new Intent(Intent.ACTION_VIEW, path, mContext, ConversationActivity.class) + .putExtra(ConversationFragment.EXTRA_SHOW_MAP, true); + + NotificationCompat.Builder messageNotificationBuilder = new NotificationCompat.Builder(mContext, NOTIF_CHANNEL_MESSAGE) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setSmallIcon(R.drawable.ic_ring_logo_white) + .setLargeIcon(getContactPicture(contact)) + .setContentText(mContext.getString(R.string.location_share_contact, contact.getDisplayName())) + .setContentIntent(PendingIntent.getActivity(mContext, random.nextInt(), intentConversation, 0)) + .setAutoCancel(false) + .setColor(ResourcesCompat.getColor(mContext.getResources(), R.color.color_primary_dark, null)); + notificationManager.notify(Objects.hash( "Location", path), messageNotificationBuilder.build()); + } + + @Override + public void cancelLocationNotification(Account first, CallContact contact) { + notificationManager.cancel(Objects.hash( "Location", ConversationPath.toUri(first.getAccountID(), contact.getPrimaryUri()))); + } + /** * Updates a notification * diff --git a/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java b/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java index e409e381687a1578b093b2644ed8094a28933f07..743f43cfc9f0f686dc24a13610ab2042c67b810b 100644 --- a/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java +++ b/ring-android/app/src/main/java/cx/ring/utils/ConversationPath.java @@ -4,7 +4,13 @@ import android.content.Intent; import android.net.Uri; import android.os.Bundle; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.jetbrains.annotations.Contract; + import java.util.List; +import java.util.Objects; import cx.ring.fragments.ConversationFragment; import cx.ring.model.Interaction; @@ -16,6 +22,12 @@ public class ConversationPath { accountId = account; contactId = contact; } + + public ConversationPath(@NonNull Tuple<String, String> path) { + accountId = path.first; + contactId = path.second; + } + public String getAccountId() { return accountId; } @@ -31,13 +43,13 @@ public class ConversationPath { builder = builder.appendEncodedPath(contactId); return builder.build(); } - public static Uri toUri(String accountId, cx.ring.model.Uri contactUri) { + public static Uri toUri(String accountId, @NonNull cx.ring.model.Uri contactUri) { Uri.Builder builder = ContentUriHandler.CONVERSATION_CONTENT_URI.buildUpon(); builder = builder.appendEncodedPath(accountId); builder = builder.appendEncodedPath(contactUri.getUri()); return builder.build(); } - public static Uri toUri(Interaction interaction) { + public static Uri toUri(@NonNull Interaction interaction) { return toUri(interaction.getAccount(), new cx.ring.model.Uri(interaction.getConversation().getParticipant())); } @@ -64,7 +76,9 @@ public class ConversationPath { } return null; } - public static ConversationPath fromBundle(Bundle bundle) { + + @Contract("null -> null") + public static ConversationPath fromBundle(@Nullable Bundle bundle) { if (bundle != null) { String accountId = bundle.getString(ConversationFragment.KEY_ACCOUNT_ID); String contactId = bundle.getString(ConversationFragment.KEY_CONTACT_RING_ID); @@ -75,7 +89,8 @@ public class ConversationPath { return null; } - public static ConversationPath fromIntent(Intent intent) { + @Contract("null -> null") + public static ConversationPath fromIntent(@Nullable Intent intent) { if (intent != null) { Uri uri = intent.getData(); ConversationPath conversationPath = fromUri(uri); @@ -85,4 +100,20 @@ public class ConversationPath { } return null; } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == this) + return true; + if (obj == null || obj.getClass() != getClass()) + return false; + ConversationPath o = (ConversationPath) obj; + return Objects.equals(o.accountId, accountId) + && Objects.equals(o.contactId, contactId); + } + + @Override + public int hashCode() { + return Objects.hash(accountId, contactId); + } } diff --git a/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.java b/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.java new file mode 100644 index 0000000000000000000000000000000000000000..a5c079b80331a6da9d7b917d9b3cc66189aa2e7f --- /dev/null +++ b/ring-android/app/src/main/java/cx/ring/utils/TouchClickListener.java @@ -0,0 +1,54 @@ +package cx.ring.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.View; + +public class TouchClickListener implements GestureDetector.OnGestureListener, View.OnTouchListener { + private final View.OnClickListener onClickListener; + private final GestureDetector mGestureDetector; + private View view; + + public TouchClickListener(Context c, View.OnClickListener l) { + onClickListener = l; + mGestureDetector = new GestureDetector(c, this); + } + + @Override + public boolean onDown(MotionEvent e) { + return false; + } + + @Override + public void onShowPress(MotionEvent e) {} + + @Override + public boolean onSingleTapUp(MotionEvent e) { + onClickListener.onClick(view); + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + return false; + } + + @Override + public void onLongPress(MotionEvent e) {} + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + return false; + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouch(View v, MotionEvent event) { + view = v; + mGestureDetector.onTouchEvent(event); + view = null; + return true; + } +} diff --git a/ring-android/app/src/main/res/drawable/baseline_close_24.xml b/ring-android/app/src/main/res/drawable/baseline_close_24.xml new file mode 100755 index 0000000000000000000000000000000000000000..16d6d37dd9f765d258b2876d79f88c162b007980 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_close_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_location_disabled_24.xml b/ring-android/app/src/main/res/drawable/baseline_location_disabled_24.xml new file mode 100755 index 0000000000000000000000000000000000000000..28a6822c6ff143f14995fe6260a1d776fc116472 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_location_disabled_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06c-1.13,0.12 -2.19,0.46 -3.16,0.97l1.5,1.5C10.16,5.19 11.06,5 12,5c3.87,0 7,3.13 7,7 0,0.94 -0.19,1.84 -0.52,2.65l1.5,1.5c0.5,-0.96 0.84,-2.02 0.97,-3.15L23,13v-2h-2.06zM3,4.27l2.04,2.04C3.97,7.62 3.25,9.23 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c1.77,-0.2 3.38,-0.91 4.69,-1.98L19.73,21 21,19.73 4.27,3 3,4.27zM16.27,17.54C15.09,18.45 13.61,19 12,19c-3.87,0 -7,-3.13 -7,-7 0,-1.61 0.55,-3.09 1.46,-4.27l9.81,9.81z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_more_vert_24.xml b/ring-android/app/src/main/res/drawable/baseline_more_vert_24.xml new file mode 100755 index 0000000000000000000000000000000000000000..34b93ecdf2a2ca9e315189c17a790e5ba3eda59b --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_more_vert_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_my_location_24.xml b/ring-android/app/src/main/res/drawable/baseline_my_location_24.xml new file mode 100755 index 0000000000000000000000000000000000000000..64266bd3923bba2207826a20e417d5977ef6e0f3 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_my_location_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94L23,13v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/baseline_navigation_24.xml b/ring-android/app/src/main/res/drawable/baseline_navigation_24.xml new file mode 100755 index 0000000000000000000000000000000000000000..421447cb3b768eaa6afc2be66c16281793be4535 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/baseline_navigation_24.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71z"/> +</vector> diff --git a/ring-android/app/src/main/res/drawable/loccationshare_bg_gradient.xml b/ring-android/app/src/main/res/drawable/loccationshare_bg_gradient.xml new file mode 100644 index 0000000000000000000000000000000000000000..41724ebf94aaac7141aec457b64c6994f6f3b124 --- /dev/null +++ b/ring-android/app/src/main/res/drawable/loccationshare_bg_gradient.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item> + <shape> + <gradient + android:angle="270" + android:startColor="@color/colorPrimaryTranslucent" + android:endColor="#00000000" + android:type="linear" /> + </shape> + </item> +</selector> \ No newline at end of file diff --git a/ring-android/app/src/main/res/drawable/msg_input_bg.xml b/ring-android/app/src/main/res/drawable/textmsg_bg_input.xml similarity index 100% rename from ring-android/app/src/main/res/drawable/msg_input_bg.xml rename to ring-android/app/src/main/res/drawable/textmsg_bg_input.xml diff --git a/ring-android/app/src/main/res/layout/frag_conversation.xml b/ring-android/app/src/main/res/layout/frag_conversation.xml index e467652f502fdb5fa27535b625f37962b8fe127d..bb93f5cdd1fbbd0f0683d31e6e24feb8cad2b21b 100644 --- a/ring-android/app/src/main/res/layout/frag_conversation.xml +++ b/ring-android/app/src/main/res/layout/frag_conversation.xml @@ -14,7 +14,8 @@ android:layout_height="match_parent" android:background="@color/background" android:clipToPadding="false" - android:paddingTop="?attr/actionBarSize"> + android:paddingTop="?attr/actionBarSize" + android:animateLayoutChanges="true"> <LinearLayout android:id="@+id/ongoingcall_pane" @@ -62,6 +63,32 @@ </LinearLayout> + <androidx.cardview.widget.CardView + android:id="@+id/mapCard" + android:layout_width="@dimen/location_sharing_minmap_width" + android:layout_height="@dimen/location_sharing_minmap_height" + android:layout_below="@id/trustRequestMessageLayout" + android:layout_centerHorizontal="true" + android:layout_marginHorizontal="0dp" + android:layout_marginVertical="16dp" + android:animateLayoutChanges="true" + android:clickable="true" + android:descendantFocusability="blocksDescendants" + android:focusable="true" + android:visibility="gone" + app:cardCornerRadius="16dp" + app:cardElevation="4dp" + tools:visibility="visible"> + + <FrameLayout + android:id="@+id/mapLayout" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:background="@color/light_green_400" + android:clickable="false" /> + + </androidx.cardview.widget.CardView> + <ProgressBar android:id="@+id/pb_loading" android:layout_width="64dp" @@ -83,6 +110,7 @@ android:paddingTop="8dp" android:paddingBottom="60dp" android:transcriptMode="normal" + android:animateLayoutChanges="false" app:layoutManager="LinearLayoutManager" app:stackFromEnd="true" tools:listitem="@layout/item_conv_msg_peer" /> @@ -103,7 +131,7 @@ android:layout_alignParentBottom="true" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" - android:layout_marginBottom="12dp" + android:layout_marginBottom="8dp" android:padding="0dp" android:visibility="gone" app:cardBackgroundColor="#4CAF50" @@ -131,7 +159,7 @@ android:layout_alignParentBottom="true" android:layout_marginLeft="8dp" android:layout_marginRight="8dp" - android:layout_marginBottom="12dp" + android:layout_marginBottom="8dp" android:padding="0dp" android:visibility="gone" app:cardCornerRadius="@dimen/conversation_message_input_radius"> @@ -187,7 +215,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" - android:background="@drawable/msg_input_bg" + android:background="@drawable/textmsg_bg_input" android:orientation="vertical" android:visibility="visible" tools:visibility="visible"> @@ -196,10 +224,10 @@ android:id="@+id/cvMessageInput" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginLeft="12dp" android:layout_marginTop="2dp" android:layout_marginRight="12dp" - android:layout_marginBottom="12dp" - android:layout_marginLeft="12dp" + android:layout_marginBottom="8dp" android:visibility="gone" app:cardCornerRadius="@dimen/conversation_message_input_radius" app:cardElevation="4dp" @@ -220,20 +248,19 @@ android:contentDescription="@string/share_label" android:onClick="@{v -> presenter.expandMenu(v)}" android:padding="8dp" - android:tint="@android:color/darker_gray" - app:srcCompat="@drawable/baseline_expand_less_24" /> + android:tint="@color/colorPrimary" + app:srcCompat="@drawable/baseline_more_vert_24" /> <ImageButton android:id="@+id/btn_take_picture" - android:layout_width="wrap_content" + android:layout_width="34dp" android:layout_height="match_parent" - android:layout_marginEnd="5dp" android:background="?selectableItemBackgroundBorderless" android:contentDescription="@string/take_a_photo" android:onClick="@{() -> presenter.takePicture()}" android:padding="8dp" - android:tint="@android:color/darker_gray" - app:srcCompat="@drawable/baseline_photo_camera_24" /> + android:tint="@color/colorPrimary" + app:srcCompat="@drawable/baseline_photo_camera_24"/> <ProgressBar android:id="@+id/pb_data_transfer" @@ -293,6 +320,7 @@ </FrameLayout> <data> + <variable name="presenter" type="cx.ring.fragments.ConversationFragment" /> diff --git a/ring-android/app/src/main/res/layout/frag_location_sharing.xml b/ring-android/app/src/main/res/layout/frag_location_sharing.xml new file mode 100644 index 0000000000000000000000000000000000000000..d9831f7fee8292be29da4fc037670c23ce920508 --- /dev/null +++ b/ring-android/app/src/main/res/layout/frag_location_sharing.xml @@ -0,0 +1,148 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:animateLayoutChanges="true" + tools:context=".fragments.LocationSharingFragment"> + + <org.osmdroid.views.MapView + android:id="@+id/map" + tilesource="Mapnik" + android:layout_width="fill_parent" + android:layout_height="fill_parent" + android:animateLayoutChanges="false" + tools:background="@color/green_400" /> + + <com.google.android.material.appbar.MaterialToolbar + android:id="@+id/locshare_toolbar" + style="@style/Widget.MaterialComponents.Toolbar.Surface" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:elevation="4dp" + app:navigationIcon="@drawable/baseline_close_24" + app:title="Location Sharing" + android:visibility="gone" + tools:subtitle="Jean is sharing his location with you" + tools:visibility="gone"/> + + <LinearLayout + android:id="@+id/locshare_snipet" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" + android:orientation="vertical"> + + <TextView + android:id="@+id/locshare_snipet_txt" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="end" + android:background="@color/colorPrimaryTranslucent" + android:paddingTop="4dp" + android:paddingHorizontal="16dp" + tools:text="Jean is sharing his location with you" + android:textColor="@color/colorOnPrimary" + android:gravity="end"/> + + <ImageView + android:id="@+id/locshare_snipet_txt_shadow" + android:layout_width="match_parent" + android:layout_height="8dp" + android:background="@drawable/loccationshare_bg_gradient" + tools:ignore="ContentDescription" /> + + </LinearLayout> + + <LinearLayout + android:id="@+id/shareControls" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" + android:animateLayoutChanges="true" + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> + + <com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/btn_center_position" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="end" + android:layout_margin="16dp" + android:src="@drawable/baseline_my_location_24" + app:backgroundTint="@color/background" + app:elevation="2dp" + app:fabSize="mini" + app:rippleColor="@color/grey_400" + app:tint="@color/colorPrimary" /> + + <com.google.android.material.chip.ChipGroup + android:id="@+id/location_share_time_group" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + app:checkedChip="@id/location_share_time_1h" + app:singleLine="true" + app:singleSelection="true"> + + <com.google.android.material.chip.Chip + android:id="@+id/location_share_time_1h" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:checkable="true" + android:checked="true" + android:text="1 hour" /> + + <com.google.android.material.chip.Chip + android:id="@+id/location_share_time_10m" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:checkable="true" + android:text="10 minutes" /> + + </com.google.android.material.chip.ChipGroup> + + <com.google.android.material.chip.Chip + android:id="@+id/location_share_time_remaining" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:checkable="false" + android:enabled="false" + android:focusable="false" + android:visibility="gone" + tools:text="43 minutes" /> + + <com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton + android:id="@+id/btn_share_location" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginBottom="@dimen/action_button_lpadding" + android:text="@string/location_share_action_start" + app:icon="@drawable/baseline_navigation_24" /> + + </LinearLayout> + + <LinearLayout + android:id="@+id/shareControlsMini" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="bottom" + android:orientation="vertical" + android:visibility="gone"> + + <com.google.android.material.chip.Chip + android:id="@+id/location_share_stop" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="center_horizontal" + android:text="@string/location_share_action_stop" + android:textColor="@color/white" + android:visibility="gone" + app:chipBackgroundColor="@color/design_default_color_error" + tools:visibility="visible" /> + </LinearLayout> +</FrameLayout> \ No newline at end of file diff --git a/ring-android/app/src/main/res/menu/conversation_share_actions.xml b/ring-android/app/src/main/res/menu/conversation_share_actions.xml index 5a318adeb74a1ba76999a1d4e34d133f4d610b20..7a453cf81a4cafdc6ea8325556a88ae6cde3b82f 100644 --- a/ring-android/app/src/main/res/menu/conversation_share_actions.xml +++ b/ring-android/app/src/main/res/menu/conversation_share_actions.xml @@ -2,6 +2,14 @@ <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> + + <item + android:id="@+id/conv_share_location" + android:icon="@drawable/baseline_navigation_24" + android:title="@string/conversation_share_location" + app:showAsAction="always" + tools:ignore="AlwaysShowAction" /> + <item android:id="@+id/conv_send_audio" android:icon="@drawable/baseline_mic_24" diff --git a/ring-android/app/src/main/res/values-night/colors.xml b/ring-android/app/src/main/res/values-night/colors.xml index 62af0603fc0269c487e8d2d28afa0ac209a9caa5..a7e82e9b14aee1ba7e9d070a2144e0420a8b985a 100644 --- a/ring-android/app/src/main/res/values-night/colors.xml +++ b/ring-android/app/src/main/res/values-night/colors.xml @@ -6,6 +6,7 @@ <color name="app_bar">@color/grey_950</color> <color name="colorPrimary">@color/white</color> + <color name="colorPrimaryTranslucent">#C0FFFFFF</color> <color name="colorOnPrimary">@color/color_primary_dark</color> <color name="colorSecondary">@color/color_primary_light</color> diff --git a/ring-android/app/src/main/res/values/app_colors.xml b/ring-android/app/src/main/res/values/app_colors.xml index d1b7b5491ff715ff9b177f1dfac43c913695a2cf..df21298618b93c0afd6e62f181d272cca77d86da 100644 --- a/ring-android/app/src/main/res/values/app_colors.xml +++ b/ring-android/app/src/main/res/values/app_colors.xml @@ -2,4 +2,7 @@ <resources> <color name="color_primary_light">#28B1ED</color> <color name="color_primary_dark">#000747</color> + + <color name="color_primary_light_translucent">#B028B1ED</color> + <color name="color_primary_dark_translucent">#B0000747</color> </resources> \ No newline at end of file diff --git a/ring-android/app/src/main/res/values/colors.xml b/ring-android/app/src/main/res/values/colors.xml index 04e3e6a6a50e4811975bb728f180f6aed7a50010..f67ff89137f0f5890e093adb5674b1dcc8b79b74 100644 --- a/ring-android/app/src/main/res/values/colors.xml +++ b/ring-android/app/src/main/res/values/colors.xml @@ -6,6 +6,7 @@ <color name="app_bar">@color/grey_25</color> <color name="colorPrimary">@color/color_primary_dark</color> + <color name="colorPrimaryTranslucent">@color/color_primary_dark_translucent</color> <color name="colorOnPrimary">@color/white</color> <color name="colorSecondary">@color/color_primary_light</color> diff --git a/ring-android/app/src/main/res/values/dimens.xml b/ring-android/app/src/main/res/values/dimens.xml index 4b374f2cef3621e3568ee4edcdb4d29aa6614992..02ad1337c44739d42d47ff52ba33dba7a17c8b29 100644 --- a/ring-android/app/src/main/res/values/dimens.xml +++ b/ring-android/app/src/main/res/values/dimens.xml @@ -33,6 +33,9 @@ along with this program; if not, write to the Free Software <dimen name="conversation_message_radius">18dp</dimen> <dimen name="conversation_message_minor_radius">3dp</dimen> <dimen name="conversation_timestamp_textsize">12sp</dimen> + <dimen name="location_sharing_avatar_size">36dp</dimen> + <dimen name="location_sharing_minmap_width">260dp</dimen> + <dimen name="location_sharing_minmap_height">160dp</dimen> <dimen name="activity_horizontal_margin">32dp</dimen> <dimen name="activity_vertical_margin">16dp</dimen> diff --git a/ring-android/app/src/main/res/values/strings.xml b/ring-android/app/src/main/res/values/strings.xml index 35616e8287ef89eb629d65de8ba73f471c1faf6c..b737cf6c1460acd82206decfbe2f6b524e76b922 100644 --- a/ring-android/app/src/main/res/values/strings.xml +++ b/ring-android/app/src/main/res/values/strings.xml @@ -132,6 +132,13 @@ along with this program; if not, write to the Free Software <string name="notif_incoming_picture">Picture from %1$s</string> <string name="action_open">Open</string> <string name="notif_sync_title">Syncing data…</string> + <string name="notif_location_title">Sharing location with %1$s</string> + <string name="location_share_contact">%1$s is sharing location with you</string> + <string name="notif_location_multi_title">Sharing location with %1$d contacts</string> + <string name="notif_location_remaining">%1$d minutes remaining</string> + <string name="notif_location_action_stop">"Stop sharing location"</string> + <string name="location_share_action_start">"Start sharing"</string> + <string name="location_share_action_stop">"Stop sharing"</string> <!-- Call Fragment --> <string name="you_txt_prefix">You:</string> @@ -167,6 +174,7 @@ along with this program; if not, write to the Free Software <string name="hist_file_received">File received</string> <string name="conversation_send_audio">Record audio clip</string> <string name="conversation_send_video">Record video clip</string> + <string name="conversation_share_location">Share location</string> <!-- MediaPreferenceFragment --> <string name="permission_dialog_camera_title">Camera permission</string> diff --git a/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationPresenter.java b/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationPresenter.java index 7611dfb72ca08cdde74d005ac72a181fb5962fd2..e091648f0cf951d7ef5aa551d145e485c376d6b7 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationPresenter.java +++ b/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationPresenter.java @@ -45,6 +45,7 @@ import cx.ring.services.HardwareService; import cx.ring.services.VCardService; import cx.ring.utils.Log; import cx.ring.utils.StringUtils; +import cx.ring.utils.Tuple; import cx.ring.utils.VCardUtils; import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; @@ -212,6 +213,15 @@ public class ConversationPresenter extends RootPresenter<ConversationView> { mConversationDisposable.add(c.getColor() .observeOn(mUiScheduler) .subscribe(view::setConversationColor, e -> Log.e(TAG, "Can't update conversation color", e))); + + Log.e(TAG, "getLocationUpdates subscribe"); + mConversationDisposable.add(account + .getLocationUpdates(c.getContact().getPrimaryUri()) + .observeOn(mUiScheduler) + .subscribe(u -> { + Log.e(TAG, "getLocationUpdates: update"); + getView().showMap(c.getAccountId(), c.getContact().getPrimaryUri().getUri(), false); + })); } public void openContact() { @@ -377,4 +387,11 @@ public class ConversationPresenter extends RootPresenter<ConversationView> { } } + public void shareLocation() { + getView().startShareLocation(mAccountId, mContactRingId.getUri()); + } + + public Tuple<String, String> getPath() { + return new Tuple<>(mAccountId, mContactRingId.getUri()); + } } diff --git a/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationView.java b/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationView.java index 7cc5504959d5d3d3a0bba8c3714851d5b95b844c..b7b357b1ef4c0c80db9a4ea3cdb3c7fbec0f1438 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationView.java +++ b/ring-android/libringclient/src/main/java/cx/ring/conversation/ConversationView.java @@ -79,4 +79,9 @@ public interface ConversationView extends BaseView { void setConversationColor(int integer); void startSaveFile(DataTransfer currentFile, String fileAbsolutePath); + + void startShareLocation(String accountId, String contactId); + + void showMap(String accountId, String contactId, boolean open); + void hideMap(); } diff --git a/ring-android/libringclient/src/main/java/cx/ring/facades/ConversationFacade.java b/ring-android/libringclient/src/main/java/cx/ring/facades/ConversationFacade.java index bac767904a94b1abee6abd18e4a7fb4502d9c10e..80115e7e72a4e693c0d0b01b2333021896b0d880 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/facades/ConversationFacade.java +++ b/ring-android/libringclient/src/main/java/cx/ring/facades/ConversationFacade.java @@ -24,6 +24,7 @@ import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.NavigableMap; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -49,6 +50,7 @@ import cx.ring.services.PreferencesService; import cx.ring.smartlist.SmartListViewModel; import cx.ring.utils.FileUtils; import cx.ring.utils.Log; +import cx.ring.utils.Tuple; import io.reactivex.Completable; import io.reactivex.Observable; import io.reactivex.Single; @@ -127,6 +129,31 @@ public class ConversationFacade { .subscribe(this::parseNewMessage, e -> Log.e(TAG, "Error adding text message", e))); + mDisposableBag.add(mAccountService.getLocationUpdates() + .concatMapSingle(location -> getAccountSubject(location.getAccount()) + .map(a -> { + long expiration = a.onLocationUpdate(location); + mDisposableBag.add(Completable.timer(expiration, TimeUnit.MILLISECONDS) + .subscribe(a::maintainLocation)); + return location; + })) + .subscribe()); + + mDisposableBag.add(mAccountService.getObservableAccountList() + .switchMap(accounts -> { + List<Observable<Tuple<Account, Account.ContactLocationEntry>>> r = new ArrayList<>(accounts.size()); + for (Account a : accounts) + r.add(a.getLocationUpdates().map(s -> new Tuple<>(a, s))); + return Observable.merge(r); + }) + .distinctUntilChanged() + .subscribe(t -> { + Log.e(TAG, "Location reception started for " + t.second.contact); + mNotificationService.showLocationNotification(t.first, t.second.contact); + mDisposableBag.add(t.second.location.doOnComplete(() -> + mNotificationService.cancelLocationNotification(t.first, t.second.contact)).subscribe()); + })); + mDisposableBag.add(mAccountService .getMessageStateChanges() .concatMapSingle(txt -> getAccountSubject(txt.getAccount()) diff --git a/ring-android/libringclient/src/main/java/cx/ring/model/Account.java b/ring-android/libringclient/src/main/java/cx/ring/model/Account.java index 987121337b0433b5a5bdee7a465064f48312d06d..8757ffea3145ad59babf1ea6406dc505f37d0c26 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/model/Account.java +++ b/ring-android/libringclient/src/main/java/cx/ring/model/Account.java @@ -26,16 +26,18 @@ import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import cx.ring.model.Interaction.InteractionStatus; +import cx.ring.services.AccountService; import cx.ring.smartlist.SmartListViewModel; import cx.ring.utils.Log; import cx.ring.utils.StringUtils; import cx.ring.utils.Tuple; import ezvcard.VCard; -import ezvcard.property.FormattedName; +import io.reactivex.Maybe; import io.reactivex.Observable; import io.reactivex.Single; import io.reactivex.subjects.BehaviorSubject; @@ -49,6 +51,7 @@ public class Account { private static final String CONTACT_CONFIRMED = "confirmed"; private static final String CONTACT_BANNED = "banned"; private static final String CONTACT_ID = "id"; + private static final int LOCATION_SHARING_EXPIRATION_MS = 1000 * 60 * 2; private final String accountID; @@ -83,6 +86,20 @@ public class Account { private final BehaviorSubject<Collection<CallContact>> contactListSubject = BehaviorSubject.create(); private final BehaviorSubject<Collection<TrustRequest>> trustRequestsSubject = BehaviorSubject.create(); + public static class ContactLocation { + public double latitude; + public double longitude; + public long timestamp; + public Date receivedDate; + } + public static class ContactLocationEntry { + public CallContact contact; + public Observable<ContactLocation> location; + } + private final Map<CallContact, Observable<ContactLocation>> contactLocations = new HashMap<>(); + private final Subject<Map<CallContact, Observable<ContactLocation>>> mLocationSubject = BehaviorSubject.createDefault(contactLocations); + private final Subject<ContactLocationEntry> mLocationStartedSubject = PublishSubject.create(); + public Single<Account> historyLoader; private VCard mProfile; private Single<Tuple<String, Object>> mLoadedProfile = null; @@ -856,6 +873,74 @@ public class Account { } } + synchronized public long onLocationUpdate(AccountService.Location location) { + Log.w(TAG, "onLocationUpdate " + location.getPeer() + " " + location.getLatitude() + ", " + location.getLongitude()); + CallContact contact = getContactFromCache(location.getPeer()); + + ContactLocation cl = new ContactLocation(); + cl.timestamp = location.getDate(); + cl.latitude = location.getLatitude(); + cl.longitude = location.getLongitude(); + cl.receivedDate = new Date(); + + Observable<ContactLocation> ls = contactLocations.get(contact); + if (ls == null) { + ls = BehaviorSubject.createDefault(cl); + contactLocations.put(contact, ls); + mLocationSubject.onNext(contactLocations); + ContactLocationEntry entry = new ContactLocationEntry(); + entry.contact = contact; + entry.location = ls; + mLocationStartedSubject.onNext(entry); + } else { + if (ls.blockingFirst().timestamp < cl.timestamp) + ((Subject<ContactLocation>) ls).onNext(cl); + } + return cl.receivedDate.getTime() + LOCATION_SHARING_EXPIRATION_MS; + } + + synchronized public void maintainLocation() { + Log.w(TAG, "maintainLocation " + contactLocations.size()); + if (contactLocations.isEmpty()) + return; + boolean changed = false; + + final Date expiration = new Date(System.currentTimeMillis() - LOCATION_SHARING_EXPIRATION_MS); + Iterator<Map.Entry<CallContact, Observable<ContactLocation>>> it = contactLocations.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry<CallContact, Observable<ContactLocation>> e = it.next(); + if (e.getValue().blockingFirst().receivedDate.before(expiration)) { + Log.w(TAG, "maintainLocation clearing " + e.getKey().getDisplayName()); + ((Subject<ContactLocation>) e.getValue()).onComplete(); + changed = true; + it.remove(); + } + } + + if (changed) + mLocationSubject.onNext(contactLocations); + } + + public Observable<ContactLocationEntry> getLocationUpdates() { + return mLocationStartedSubject; + } + + public Observable<Map<CallContact, Observable<ContactLocation>>> getLocationsUpdates() { + return mLocationSubject; + } + + public Observable<Observable<ContactLocation>> getLocationUpdates(Uri contactId) { + CallContact contact = getContactFromCache(contactId); + Log.w(TAG, "getLocationUpdates " + contactId + " " + contact); + return mLocationSubject + .flatMapMaybe(locations -> { + Observable<ContactLocation> r = locations.get(contact); + Log.w(TAG, "getLocationUpdates flatMapMaybe " + locations.size() + " " + r); + return r == null ? Maybe.empty() : Maybe.just(r); + }) + .distinctUntilChanged(); + } + public Single<String> getAccountAlias() { if (mLoadedProfile == null) return Single.just(getAlias()); diff --git a/ring-android/libringclient/src/main/java/cx/ring/model/SipCall.java b/ring-android/libringclient/src/main/java/cx/ring/model/SipCall.java index 2fcbc9f46a142cb4440ae49501081ee45bf7c9ef..df8face377c89551656f4d84259d3d792e74f820 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/model/SipCall.java +++ b/ring-android/libringclient/src/main/java/cx/ring/model/SipCall.java @@ -227,7 +227,7 @@ public class SipCall extends Interaction { for (Map.Entry<String, String> message : messages.entrySet()) { HashMap<String, String> messageKeyValue = VCardUtils.parseMimeAttributes(message.getKey()); String mimeType = messageKeyValue.get(VCardUtils.VCARD_KEY_MIME_TYPE); - if (!VCardUtils.MIME_RING_PROFILE_VCARD.equals(mimeType)) { + if (!VCardUtils.MIME_PROFILE_VCARD.equals(mimeType)) { continue; } int part = Integer.parseInt(messageKeyValue.get(VCardUtils.VCARD_KEY_PART)); diff --git a/ring-android/libringclient/src/main/java/cx/ring/services/AccountService.java b/ring-android/libringclient/src/main/java/cx/ring/services/AccountService.java index c0f3a4ea97ee4d2c23d8ed06b307f35360579b37..71e8d6017cffc751c5b703fcc757e65e828cd5b0 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/services/AccountService.java +++ b/ring-android/libringclient/src/main/java/cx/ring/services/AccountService.java @@ -20,6 +20,9 @@ */ package cx.ring.services; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + import java.io.File; import java.net.SocketException; import java.util.ArrayList; @@ -30,14 +33,12 @@ import java.util.Random; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.atomic.AtomicBoolean; import javax.inject.Inject; import javax.inject.Named; import cx.ring.daemon.Blob; import cx.ring.daemon.DataTransferInfo; -import cx.ring.daemon.Message; import cx.ring.daemon.Ringservice; import cx.ring.daemon.StringMap; import cx.ring.daemon.StringVect; @@ -63,6 +64,7 @@ import cx.ring.utils.SwigNativeConverter; import cx.ring.utils.VCardUtils; import ezvcard.VCard; import io.reactivex.Completable; +import io.reactivex.Maybe; import io.reactivex.Observable; import io.reactivex.Single; import io.reactivex.schedulers.Schedulers; @@ -104,7 +106,6 @@ public class AccountService { private List<Account> mAccountList = new ArrayList<>(); private boolean mHasSipAccount; private boolean mHasRingAccount; - private AtomicBoolean mAccountsLoaded = new AtomicBoolean(false); private final HashMap<Long, DataTransfer> mDataTransfers = new HashMap<>(); private DataTransfer mStartingTransfer = null; @@ -117,9 +118,81 @@ public class AccountService { .map(l -> l.get(0)) .distinctUntilChanged(); - private final Subject<TextMessage> incomingMessageSubject = PublishSubject.create(); - private final Subject<TextMessage> messageSubject = PublishSubject.create(); + public static class Message { + String accountId; + String callId; + String author; + Map<String, String> messages; + } + public static class Location { + String accountId; + String callId; + Uri peer; + long time; + double latitude; + double longitude; + + public String getAccount() { + return accountId; + } + + public Uri getPeer() { + return peer; + } + + public long getDate() { + return time; + } + + public double getLatitude() { + return latitude; + } + + public double getLongitude() { + return longitude; + } + } + + private final Subject<Message> incomingMessageSubject = PublishSubject.create(); + + private final Observable<TextMessage> incomingTextMessageSubject = incomingMessageSubject + .flatMapMaybe(msg -> { + String message = msg.messages.get(CallService.MIME_TEXT_PLAIN); + if (message != null) { + return mHistoryService + .incomingMessage(msg.accountId, msg.callId, msg.author, message) + .toMaybe(); + } + return Maybe.empty(); + }) + .share(); + + private final Observable<Location> incomingLocationSubject = incomingMessageSubject + .flatMapMaybe(msg -> { + try { + String loc = msg.messages.get(CallService.MIME_GEOLOCATION); + if (loc == null) + return Maybe.empty(); + + JsonObject obj = JsonParser.parseString(loc).getAsJsonObject(); + if (obj.size() < 2) + return Maybe.empty(); + Location l = new Location(); + l.latitude = obj.get("lat").getAsDouble(); + l.longitude = obj.get("long").getAsDouble(); + l.time = obj.get("time").getAsLong(); + l.accountId = msg.accountId; + l.callId = msg.callId; + l.peer = new Uri(msg.author); + return Maybe.just(l); + } catch (Exception e) { + Log.w(TAG, "Failed to receive geolocation", e); + return Maybe.empty(); + } + }) + .share(); + private final Subject<TextMessage> textMessageSubject = PublishSubject.create(); private final Subject<DataTransfer> dataTransferSubject = PublishSubject.create(); private final Subject<TrustRequest> incomingRequestsSubject = PublishSubject.create(); @@ -127,7 +200,7 @@ public class AccountService { accountsSubject.onNext(mAccountList); } - public class RegisteredName { + public static class RegisteredName { public String accountId; public String name; public String address; @@ -136,19 +209,19 @@ public class AccountService { private final Subject<RegisteredName> registeredNameSubject = PublishSubject.create(); - private class ExportOnRingResult { + private static class ExportOnRingResult { String accountId; int code; String pin; } - private class DeviceRevocationResult { + private static class DeviceRevocationResult { String accountId; String deviceId; int code; } - private class MigrationResult { + private static class MigrationResult { String accountId; String state; } @@ -162,11 +235,15 @@ public class AccountService { } public Observable<TextMessage> getIncomingMessages() { - return incomingMessageSubject; + return incomingTextMessageSubject; + } + + public Observable<Location> getLocationUpdates() { + return incomingLocationSubject; } public Observable<TextMessage> getMessageStateChanges() { - return messageSubject; + return textMessageSubject; } public Observable<TrustRequest> getIncomingRequests() { @@ -187,10 +264,6 @@ public class AccountService { return mHasRingAccount; } - public boolean isLoaded() { - return mAccountsLoaded.get(); - } - /** * Loads the accounts from the daemon and then builds the local cache (also sends ACCOUNTS_CHANGED event) * @@ -205,7 +278,6 @@ public class AccountService { private void refreshAccountsCacheFromDaemon() { Log.w(TAG, "refreshAccountsCacheFromDaemon"); - mAccountsLoaded.set(false); boolean hasSip = false, hasJami = false; List<Account> curList = mAccountList; List<String> accountIds = new ArrayList<>(Ringservice.getAccountList()); @@ -300,7 +372,6 @@ public class AccountService { }, e -> Log.e(TAG, "Error completing profile migration", e)); accountsSubject.onNext(newAccounts); - mAccountsLoaded.set(true); } private Account getAccountByName(final String name) { @@ -499,7 +570,7 @@ public class AccountService { while (i <= nbTotal) { HashMap<String, String> chunk = new HashMap<>(); Log.d(TAG, "length vcard " + stringVCard.length() + " id " + key + " part " + i + " nbTotal " + nbTotal); - String keyHashMap = VCardUtils.MIME_RING_PROFILE_VCARD + "; id=" + key + ",part=" + i + ",of=" + nbTotal; + String keyHashMap = VCardUtils.MIME_PROFILE_VCARD + "; id=" + key + ",part=" + i + ",of=" + nbTotal; String message = stringVCard.substring(0, Math.min(VCARD_CHUNK_SIZE, stringVCard.length())); chunk.put(keyHashMap, message); Ringservice.sendTextMessage(callId, StringMap.toSwig(chunk), "Me", false); @@ -738,9 +809,7 @@ public class AccountService { mExecutor.execute(() -> { UintVect list = new UintVect(); list.reserve(codecs.size()); - for (Long codec : codecs) { - list.add(codec); - } + list.addAll(codecs); Ringservice.setActiveCodecList(accountId, list); accountSubject.onNext(getAccount(accountId)); }); @@ -1167,19 +1236,19 @@ public class AccountService { void incomingAccountMessage(String accountId, String callId, String from, Map<String, String> messages) { Log.d(TAG, "incomingAccountMessage: " + accountId + " " + messages.size()); - String message = messages.get(CallService.MIME_TEXT_PLAIN); - if (message != null) { - mHistoryService - .incomingMessage(accountId, callId, from, message) - .subscribe(incomingMessageSubject::onNext); - } + Message message = new Message(); + message.accountId = accountId; + message.callId = callId; + message.author = from; + message.messages = messages; + incomingMessageSubject.onNext(message); } void accountMessageStatusChanged(String accountId, long messageId, String to, int status) { Log.d(TAG, "accountMessageStatusChanged: " + accountId + ", " + messageId + ", " + to + ", " + status); mHistoryService .accountMessageStatusChanged(accountId, messageId, to, status) - .subscribe(messageSubject::onNext, e -> Log.e(TAG, "Error updating message", e)); + .subscribe(textMessageSubject::onNext, e -> Log.e(TAG, "Error updating message: " + e.getLocalizedMessage())); } void errorAlert(int alert) { @@ -1322,7 +1391,7 @@ public class AccountService { return DataTransferError.UNKNOWN; } - public List<Message> getLastMessages(String accountId, long baseTime) { + public List<cx.ring.daemon.Message> getLastMessages(String accountId, long baseTime) { try { return mExecutor.submit(() -> SwigNativeConverter.toJava(Ringservice.getLastMessages(accountId, baseTime))).get(); } catch (Exception e) { diff --git a/ring-android/libringclient/src/main/java/cx/ring/services/CallService.java b/ring-android/libringclient/src/main/java/cx/ring/services/CallService.java index 8db3dea3c42f7053e35b7f74ba852cf786c91880..e673b0d6f5dc51eb577d04d7b8f5f0b4541b57c9 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/services/CallService.java +++ b/ring-android/libringclient/src/main/java/cx/ring/services/CallService.java @@ -56,6 +56,7 @@ public class CallService { private final static String TAG = CallService.class.getSimpleName(); public final static String MIME_TEXT_PLAIN = "text/plain"; + public static final String MIME_GEOLOCATION = "application/geo"; @Inject @Named("DaemonExecutor") diff --git a/ring-android/libringclient/src/main/java/cx/ring/services/NotificationService.java b/ring-android/libringclient/src/main/java/cx/ring/services/NotificationService.java index 4fcf8eb9e3c83cb398e82b0bd304cfa969a4e4de..c248abd4d95d5578089b6bca2f44d4eb5504ac1b 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/services/NotificationService.java +++ b/ring-android/libringclient/src/main/java/cx/ring/services/NotificationService.java @@ -69,4 +69,8 @@ public interface NotificationService { Object getDataTransferNotification(int notificationId); void onConnectionUpdate(Boolean b); + + void showLocationNotification(Account first, CallContact contact); + void cancelLocationNotification(Account first, CallContact contact); + } diff --git a/ring-android/libringclient/src/main/java/cx/ring/utils/VCardUtils.java b/ring-android/libringclient/src/main/java/cx/ring/utils/VCardUtils.java index 304a66fc1c78195b3707883ba7ff289522faf5a4..4744306abd1e72c5a82a92b0313271bb4285092b 100644 --- a/ring-android/libringclient/src/main/java/cx/ring/utils/VCardUtils.java +++ b/ring-android/libringclient/src/main/java/cx/ring/utils/VCardUtils.java @@ -37,7 +37,7 @@ import io.reactivex.schedulers.Schedulers; public final class VCardUtils { public static final String TAG = VCardUtils.class.getSimpleName(); - public static final String MIME_RING_PROFILE_VCARD = "x-ring/ring.profile.vcard"; + public static final String MIME_PROFILE_VCARD = "x-ring/ring.profile.vcard"; public static final String VCARD_KEY_MIME_TYPE = "mimeType"; public static final String VCARD_KEY_PART = "part"; public static final String VCARD_KEY_OF = "of";