| /* |
| * Copyright (C) 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.car.messenger.impl.datamodels; |
| |
| import static com.android.car.messenger.impl.datamodels.util.ConversationFetchUtil.fetchConversation; |
| import static com.android.car.messenger.impl.datamodels.util.CursorUtils.DEFAULT_SORT_ORDER; |
| |
| import android.content.Context; |
| import android.database.Cursor; |
| import android.database.CursorIndexOutOfBoundsException; |
| import android.net.Uri; |
| import android.provider.Telephony; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| |
| import com.android.car.messenger.common.Conversation; |
| import com.android.car.messenger.core.interfaces.AppFactory; |
| import com.android.car.messenger.core.models.UserAccount; |
| import com.android.car.messenger.core.shared.MessageConstants; |
| import com.android.car.messenger.core.util.CarStateListener; |
| import com.android.car.messenger.core.util.ConversationUtil; |
| import com.android.car.messenger.core.util.L; |
| |
| import java.time.Instant; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.Locale; |
| import java.util.Objects; |
| |
| /** |
| * Publishes a stream of {@link Conversation} with unread messages that was received on the user |
| * device after the car's connection to the{@link UserAccount}. |
| */ |
| public class NewMessageLiveData extends ContentProviderLiveData<Conversation> { |
| @NonNull |
| private final UserAccountLiveData mUserAccountLiveData = UserAccountLiveData.getInstance(); |
| |
| @NonNull private Collection<UserAccount> mUserAccounts = new ArrayList<>(); |
| @NonNull private final HashMap<Integer, Instant> mOffsetMap = new HashMap<>(); |
| |
| @NonNull |
| private static final String MESSAGE_QUERY = |
| Telephony.TextBasedSmsColumns.DATE |
| + " > %d AND " |
| + Telephony.TextBasedSmsColumns.SUBSCRIPTION_ID |
| + " = %d"; |
| |
| @NonNull |
| private final CarStateListener mCarStateListener = AppFactory.get().getCarStateListener(); |
| |
| NewMessageLiveData() { |
| super(Telephony.Sms.CONTENT_URI, Telephony.Mms.CONTENT_URI, Telephony.MmsSms.CONTENT_URI); |
| } |
| |
| @Override |
| protected void onActive() { |
| super.onActive(); |
| addSource( |
| mUserAccountLiveData, |
| it -> { |
| mUserAccounts = it.getAccounts(); |
| it.getRemovedAccounts() |
| .forEach(userAccount -> mOffsetMap.remove(userAccount.getId())); |
| }); |
| if (getValue() == null) { |
| onDataChange(); |
| } |
| } |
| |
| @Override |
| protected void onInactive() { |
| super.onInactive(); |
| removeSource(mUserAccountLiveData); |
| mUserAccounts.clear(); |
| mOffsetMap.clear(); |
| } |
| |
| @Override |
| public void onDataChange() { |
| for (UserAccount userAccount : mUserAccounts) { |
| if (hasProjectionInForeground(userAccount)) { |
| continue; |
| } |
| Instant offset = |
| Objects.requireNonNull( |
| mOffsetMap.getOrDefault( |
| userAccount.getId(), userAccount.getConnectionTime())); |
| Cursor mmsCursor = getMmsCursor(userAccount, offset); |
| boolean foundNewMms = postNewMessageIfFound(mmsCursor, userAccount); |
| Cursor smsCursor = getSmsCursor(userAccount, offset); |
| boolean foundNewSms = postNewMessageIfFound(smsCursor, userAccount); |
| if (foundNewMms || foundNewSms) { |
| // onDataChange is called per one message insert, |
| // so once a new message is found we can exit early |
| break; |
| } |
| } |
| } |
| |
| /** Post a new message if one is found, and returns true if so, false otherwise */ |
| private boolean postNewMessageIfFound( |
| @Nullable Cursor cursor, @NonNull UserAccount userAccount) { |
| if (cursor == null || !cursor.moveToFirst()) { |
| return false; |
| } |
| String conversationId = |
| cursor.getString(cursor.getColumnIndex(Telephony.TextBasedSmsColumns.THREAD_ID)); |
| |
| Conversation conversation; |
| try { |
| conversation = fetchConversation(conversationId); |
| } catch (CursorIndexOutOfBoundsException e) { |
| L.w("Error occurred fetching conversation Id " + conversationId); |
| return false; |
| } |
| conversation.getExtras().putInt(MessageConstants.EXTRA_ACCOUNT_ID, userAccount.getId()); |
| Instant offset = |
| Instant.ofEpochMilli(ConversationUtil.getConversationTimestamp(conversation)); |
| mOffsetMap.put(userAccount.getId(), offset); |
| postValue(conversation); |
| return true; |
| } |
| |
| /** Get the last message cursor, taking into account the last message posted */ |
| @Nullable |
| private Cursor getMmsCursor(@NonNull UserAccount userAccount, @NonNull Instant offset) { |
| return getCursor(Telephony.Mms.Inbox.CONTENT_URI, userAccount, offset.getEpochSecond()); |
| } |
| |
| /** Get the last message cursor, taking into account the last message posted */ |
| @Nullable |
| private Cursor getSmsCursor(@NonNull UserAccount userAccount, @NonNull Instant offset) { |
| return getCursor(Telephony.Sms.Inbox.CONTENT_URI, userAccount, offset.toEpochMilli()); |
| } |
| /** Get the last message cursor, taking into account an offset and subscription id */ |
| @Nullable |
| private Cursor getCursor(Uri uri, @NonNull UserAccount userAccount, long offset) { |
| Context context = AppFactory.get().getContext(); |
| String query = String.format(Locale.ENGLISH, MESSAGE_QUERY, offset, userAccount.getId()); |
| return context.getContentResolver() |
| .query( |
| uri, |
| new String[] {Telephony.TextBasedSmsColumns.THREAD_ID}, |
| query, |
| /* selectionArgs= */ null, |
| DEFAULT_SORT_ORDER + " LIMIT 1"); |
| } |
| |
| private boolean hasProjectionInForeground(@NonNull UserAccount userAccount) { |
| return mCarStateListener.isProjectionInActiveForeground(userAccount.getIccId()); |
| } |
| } |