Implement in-app insights.

master
Alex Hart 2019-11-12 10:18:57 -04:00 committed by Alan Evans
parent e2e9cd40b3
commit a7dd78cce6
56 changed files with 2541 additions and 127 deletions

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?icon_tint"
android:pathData="M20.5,4.5l-1,-1l-7.5,7.4l-7.5,-7.4l-1,1l7.4,7.5l-7.4,7.5l1,1l7.5,-7.4l7.5,7.4l1,-1l-7.4,-7.5z"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/signal_primary" />
<corners android:radius="2dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/white" />
<corners android:radius="4dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/core_grey_75" />
<corners android:radius="4dp" />
</shape>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape
android:innerRadius="20dp"
android:shape="ring"
android:thickness="6dp"
android:useLevel="false">
<solid android:color="@color/transparent_black_40" />
</shape>
</item>
<item>
<shape
android:innerRadius="20dp"
android:shape="ring"
android:thickness="6dp"
android:useLevel="true">
<solid android:color="@color/white" />
</shape>
</item>
</layer-list>

View File

@ -0,0 +1,200 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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">
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:fillViewport="true"
app:layout_constraintBottom_toTopOf="@id/insights_dashboard_this_stat_was_generated_locally"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="?actionBarSize">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/insights_dashboard_lottie_animation"
android:layout_width="0dip"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/insights_dashboard_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/insights_dashboard_progress"
app:lottie_rawRes="@raw/lottie_insights_100" />
<org.thoughtcrime.securesms.components.ArcProgressBar
android:id="@+id/insights_dashboard_progress"
style="@style/Widget.Signal.ArcProgressBar"
android:layout_width="187dp"
android:layout_height="187dp"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/insights_dashboard_avatar"
android:layout_width="140dp"
android:layout_height="140dp"
app:layout_constraintBottom_toBottomOf="@id/insights_dashboard_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/insights_dashboard_progress" />
<LinearLayout
android:id="@+id/insights_dashboard_percent_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_dashboard_avatar">
<TextView
android:id="@+id/insights_dashboard_percent_secure"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Signal.Headline.Insights"
tools:text="100" />
<TextView
android:id="@+id/insights_dashboard_percent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="1dp"
android:gravity="center"
android:text="@string/Insights__percent"
android:textAppearance="@style/TextAppearance.Signal.SubHead.Insights" />
</LinearLayout>
<TextView
android:id="@+id/insights_dashboard_encrypted_messages"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="@string/InsightsDashboardFragment__encrypted_messages"
android:textAppearance="@style/TextAppearance.Signal.SubHead.Insights"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_dashboard_percent_container" />
<TextView
android:id="@+id/insights_dashboard_tagline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:lineSpacingMultiplier="1.2"
android:textAppearance="@style/TextAppearance.Signal.Body.Insights"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_dashboard_encrypted_messages"
tools:text="100% of your outgoing messages in the past 7 days were end-to-end encrypted with Signal Protocol." />
<Button
android:id="@+id/insights_dashboard_start_a_conversation"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="22dp"
android:layout_marginEnd="16dp"
android:text="@string/InsightsDashboardFragment__start_a_conversation"
android:textAppearance="@style/TextAppearance.Signal.Caption.Insights"
android:textColor="@color/signal_primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_dashboard_tagline" />
<TextView
android:id="@+id/insights_dashboard_make_signal_secure"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/InsightsDashboardFragment__boost_your_signal"
android:textAppearance="@style/TextAppearance.Signal.SubHead.Insights"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_dashboard_tagline" />
<TextView
android:id="@+id/insights_dashboard_invite_your_contacts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:gravity="center"
android:lineSpacingMultiplier="1.2"
android:text="@string/InsightsDashboardFragment__invite_your_contacts"
android:textAppearance="@style/TextAppearance.Signal.Body.Insights"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_dashboard_make_signal_secure" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/insights_dashboard_recycler"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constrainedHeight="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_dashboard_invite_your_contacts"
tools:itemCount="10"
tools:listitem="@layout/insights_dashboard_adapter_item" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
<androidx.appcompat.widget.Toolbar
android:id="@+id/insights_dashboard_toolbar"
style="?actionBarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?android:windowBackground"
android:theme="?actionBarStyle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="@drawable/ic_x_tinted"
app:title="@string/InsightsDashboardFragment__title" />
<TextView
android:id="@+id/insights_dashboard_this_stat_was_generated_locally"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?insight_dashboard_bottom_bar_background"
android:gravity="center"
android:lineSpacingMultiplier="1.2"
android:paddingStart="16dp"
android:paddingTop="24dp"
android:paddingEnd="16dp"
android:paddingBottom="24dp"
android:text="@string/InsightsDashboardFragment__this_stat_was_generated_locally"
android:textAppearance="@style/TextAppearance.Signal.Body2.Insights"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="68dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/recipient_avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/recipient_display_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="9dp"
android:textAppearance="@style/TextAppearance.Signal.Title.Insights"
app:layout_constraintBottom_toTopOf="@id/recipient_insecure_contribution"
app:layout_constraintStart_toEndOf="@id/recipient_avatar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="Cayce Pollard" />
<TextView
android:id="@+id/recipient_insecure_contribution"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="9dp"
android:layout_marginTop="1dp"
android:text="@string/InsightsDashboardFragment__not_using_signal_yet"
android:textAppearance="@style/TextAppearance.Signal.Body.Insights"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/recipient_avatar"
app:layout_constraintTop_toBottomOf="@id/recipient_display_name"
app:layout_constraintVertical_chainStyle="packed" />
<Button
android:id="@+id/recipient_invite"
style="@style/Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/conversation_insecure__invite"
android:textAppearance="@style/TextAppearance.Signal.Caption.Insights"
android:textColor="@color/signal_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="wrap_content"
android:background="?insight_modal_background"
android:orientation="vertical">
<ImageView
android:id="@+id/insights_modal_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:tint="?icon_tint"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_x" />
<TextView
android:id="@+id/insights_modal_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/insights_modal_title_margin_top"
android:text="@string/InsightsModalFragment__title"
android:textAppearance="@style/TextAppearance.Signal.SubHead.Insights"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/insights_modal_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="@dimen/insights_modal_description_margin_top"
android:layout_marginEnd="16dp"
android:gravity="center"
android:lineSpacingMultiplier="1.2"
android:text="@string/InsightsModalFragment__description"
android:textAppearance="@style/TextAppearance.Signal.Title.Insights"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_modal_title" />
<org.thoughtcrime.securesms.components.ArcProgressBar
android:id="@+id/insights_modal_progress"
style="@style/Widget.Signal.ArcProgressBar"
android:layout_width="@dimen/insights_modal_progress_size"
android:layout_height="@dimen/insights_modal_progress_size"
android:layout_marginTop="@dimen/insights_modal_progress_margin_top"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_modal_description" />
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/insights_modal_avatar"
android:layout_width="@dimen/insights_modal_avatar_size"
android:layout_height="@dimen/insights_modal_avatar_size"
app:layout_constraintBottom_toBottomOf="@id/insights_modal_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/insights_modal_progress" />
<LinearLayout
android:id="@+id/insights_modal_percent_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_modal_avatar">
<TextView
android:id="@+id/insights_modal_percent_secure"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textAppearance="@style/TextAppearance.Signal.Headline.Insights.Modal.Percent"
tools:text="100" />
<TextView
android:id="@+id/insights_modal_percent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:gravity="center"
android:text="@string/Insights__percent"
android:textAppearance="@style/TextAppearance.Signal.SubHead.Insights.Modal.Percent" />
</LinearLayout>
<Button
android:id="@+id/insights_modal_view_insights"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="@dimen/insights_modal_view_insights_margin_top"
android:layout_marginEnd="16dp"
android:layout_marginBottom="20dp"
android:background="@drawable/insights_cta_button_background"
android:text="@string/InsightsModalFragment__view_insights"
android:textAppearance="@style/TextAppearance.Signal.Caption.Insights"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/insights_modal_progress" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<Button xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.AppCompat.Button.Borderless"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:textColor="@color/white"
android:textSize="16sp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:layout_marginStart="3dp"
tools:text="Action" />

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/container"
android:layout_width="match_parent"
@ -12,33 +12,75 @@
android:visibility="gone"
tools:visibility="visible">
<LinearLayout
android:id="@+id/reminder"
android:layout_width="0dp"
<ProgressBar
android:id="@+id/reminder_progress"
style="@style/Widget.ProgressBar.Horizontal"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:progressDrawable="@drawable/reminder_progress_ring"
android:rotation="90"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:progress="10" />
<TextView
android:id="@+id/reminder_progress_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="16dp"
android:orientation="vertical">
android:textColor="@color/white"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/reminder_progress"
app:layout_constraintEnd_toEndOf="@id/reminder_progress"
app:layout_constraintStart_toStartOf="@id/reminder_progress"
app:layout_constraintTop_toTopOf="@id/reminder_progress"
tools:text="100%" />
<TextView
android:id="@+id/reminder_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="Invite to Signal"/>
<TextView
android:id="@+id/reminder_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="14dp"
android:layout_marginBottom="6dp"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@id/reminder_text"
app:layout_constraintStart_toEndOf="@id/reminder_progress"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginStart="16dp"
tools:text="Invite to Signal" />
<TextView
android:id="@+id/reminder_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-light"
android:textColor="@color/white"
android:textSize="16sp"
tools:text="Take your conversation with Jules Bonnot to the next level."/>
<TextView
android:id="@+id/reminder_text"
android:layout_width="0dip"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:textColor="@color/white"
android:textSize="16sp"
app:layout_constraintBottom_toTopOf="@id/reminder_actions"
app:layout_constraintEnd_toStartOf="@id/reminder_space"
app:layout_constraintStart_toEndOf="@id/reminder_progress"
app:layout_constraintTop_toBottomOf="@id/reminder_title"
app:layout_goneMarginBottom="12dp"
app:layout_goneMarginEnd="16dp"
app:layout_goneMarginStart="16dp"
app:layout_goneMarginTop="15dp"
tools:text="Take your conversation with Jules Bonnot to the next level." />
</LinearLayout>
<Space
android:id="@+id/reminder_space"
android:layout_width="48dp"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageButton
android:id="@+id/cancel"
@ -46,10 +88,27 @@
android:layout_height="48dp"
android:layout_marginTop="2dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/InviteActivity_cancel"
android:focusable="true"
android:nextFocusLeft="@+id/container"
android:nextFocusRight="@+id/container"
android:src="@drawable/ic_close_white_24dp"
android:contentDescription="@string/InviteActivity_cancel"/>
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/reminder_actions"
android:layout_width="0dip"
android:layout_height="46dp"
android:orientation="horizontal"
android:overScrollMode="never"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:reverseLayout="true"
tools:itemCount="2"
tools:listitem="@layout/reminder_action_button" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ProgressBar xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.ProgressBar.Horizontal"
android:progressDrawable="@drawable/reminder_progress_ring"
tools:progress="10"
android:rotation="90"
android:layout_width="52dp"
android:layout_height="52dp" />

View File

@ -16,6 +16,10 @@
<item android:title="@string/text_secure_normal__menu_settings"
android:id="@+id/menu_settings" />
<item android:title="@string/Insights__title"
android:id="@+id/menu_insights"
android:visible="false" />
<item android:title="@string/text_secure_normal__help"
android:id="@+id/menu_help"/>

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<integer name="media_overview_cols">5</integer>
<dimen name="insights_modal_title_margin_top">12dp</dimen>
<dimen name="insights_modal_description_margin_top">10dp</dimen>
<dimen name="insights_modal_progress_margin_top">17dp</dimen>
<dimen name="insights_modal_progress_size">131dp</dimen>
<dimen name="insights_modal_avatar_size">94dp</dimen>
<dimen name="insights_modal_percent_margin_top">2dp</dimen>
<dimen name="insights_modal_percent_sign_margin_top">6dp</dimen>
<dimen name="insights_modal_view_insights_margin_top">31dp</dimen>
<dimen name="insights_modal_percent_text_size">20sp</dimen>
<dimen name="insights_modal_percent_sign_text_size">16sp</dimen>
</resources>

View File

@ -35,6 +35,13 @@
<attr name="fab_color" format="reference|color" />
<attr name="lower_right_divet" format="reference" />
<attr name="insight_modal_background" format="reference" />
<attr name="insight_modal_button_background" format="color" />
<attr name="insight_title" format="color" />
<attr name="insight_body_2" format="color" />
<attr name="insight_dashboard_bottom_bar_background" format="color" />
<attr name="insight_progress_background" format="color" />
<attr name="centered_app_title_color" format="reference|color" />
<attr name="ic_arrow_forward" format="reference" />
<attr name="ic_visibility" format="reference" />

View File

@ -91,6 +91,17 @@
<dimen name="unread_count_bubble_radius">13sp</dimen>
<dimen name="unread_count_bubble_diameter">26sp</dimen>
<dimen name="insights_modal_title_margin_top">41dp</dimen>
<dimen name="insights_modal_description_margin_top">12dp</dimen>
<dimen name="insights_modal_progress_margin_top">23dp</dimen>
<dimen name="insights_modal_progress_size">187dp</dimen>
<dimen name="insights_modal_avatar_size">140dp</dimen>
<dimen name="insights_modal_percent_margin_top">6dp</dimen>
<dimen name="insights_modal_percent_sign_margin_top">15dp</dimen>
<dimen name="insights_modal_view_insights_margin_top">41dp</dimen>
<dimen name="insights_modal_percent_text_size">28sp</dimen>
<dimen name="insights_modal_percent_sign_text_size">20sp</dimen>
<!-- RedPhone -->
<!-- Height of the main row of in-call buttons. -->

View File

@ -3,4 +3,7 @@
<item type="id" name="holder_tag"/>
<item type="id" name="contact_info_tag"/>
<item type="id" name="motion_view_edittext"/>
<item name="reminder_action_view_insights" type="id" />
<item name="reminder_action_invite" type="id" />
</resources>

View File

@ -1517,6 +1517,7 @@
<string name="reminder_header_share_text">The more friends use Signal, the better it gets.</string>
<string name="reminder_header_service_outage_text">Signal is experiencing technical difficulties. We are working hard to restore service as quickly as possible.</string>
<string name="reminder_header_the_latest_signal_features_wont_work">The latest Signal features won\'t work on this version of Android. Please upgrade this device to receive future Signal updates.</string>
<string name="reminder_header_progress">%1$d%%</string>
<!-- media_preview -->
<string name="media_preview__save_title">Save</string>
@ -1538,6 +1539,34 @@
<string name="trimmer__deleting_old_messages">Deleting old messages…</string>
<string name="trimmer__old_messages_successfully_deleted">Old messages successfully deleted</string>
<!-- Insights -->
<string name="Insights__percent">%</string>
<string name="Insights__title">Insights</string>
<string name="InsightsDashboardFragment__title">Insights</string>
<string name="InsightsDashboardFragment__tagline">%1$d%% of your outgoing messages in the past 7 days were end-to-end encrypted with Signal Protocol.</string>
<string name="InsightsDashboardFragment__boost_your_signal">Boost your Signal</string>
<string name="InsightsDashboardFragment__100_title">Your Signal is Strong</string>
<string name="InsightsDashboardFragment__no_signal_yet">No Signal (yet)</string>
<string name="InsightsDashboardFragment__youre_just_getting_started">You\'re just getting started. Insights will be displayed after you send a few messages.</string>
<string name="InsightsDashboardFragment__start_a_conversation">Start a conversation</string>
<string name="InsightsDashboardFragment__100_description">Signal\'s advanced privacy-preserving technology automatically protected all of your recent outgoing messages.</string>
<string name="InsightsDashboardFragment__invite_your_contacts">Start communicating securely and enable new features that go beyond the limitations of unencrypted SMS messages by inviting more contacts to join Signal.</string>
<string name="InsightsDashboardFragment__this_stat_was_generated_locally">This stat was locally generated on your device and can only be seen by you. It is never transmitted anywhere.</string>
<string name="InsightsDashboardFragment__encrypted_messages">Encrypted messages</string>
<string name="InsightsDashboardFragment__cancel">Cancel</string>
<string name="InsightsDashboardFragment__send">Send</string>
<string name="InsightsDashboardFragment__not_using_signal_yet">Not using Signal yet</string>
<string name="InsightsModalFragment__title">Introducing Insights</string>
<string name="InsightsModalFragment__description">Find out how many of your outgoing messages were sent securely, then quickly invite new contacts to boost your Signal percentage.</string>
<string name="InsightsModalFragment__view_insights">View Insights</string>
<string name="FirstInviteReminder__title">Invite to Signal</string>
<string name="FirstInviteReminder__description">You could increase the number of encrypted messages you send by %1$d%%</string>
<string name="SecondInviteReminder__title">Boost your Signal</string>
<string name="SecondInviteReminder__description">Invite %1$s</string>
<string name="InsightsReminder__view_insights">View Insights</string>
<string name="InsightsReminder__invite">Invite</string>
<!-- transport_selection_list_item -->
<string name="transport_selection_list_item__transport_icon">Transport icon</string>
<string name="ConversationListFragment_loading">Loading…</string>

View File

@ -232,7 +232,7 @@
<item name="android:inputType">textAutoCorrect|textCapSentences|textMultiLine</item>
<item name="android:contentDescription">@string/conversation_activity__compose_description</item>
</style>
<style name="AttachmentTypeLabel">
<item name="android:textColor">#ff999999</item>
<item name="android:textSize">14sp</item>
@ -280,7 +280,6 @@
<item name="android:focusable">false</item>
</style>
<style name="PreferenceThemeOverlay.Fix" parent="PreferenceThemeOverlay.v14.Material">
<item name="android:divider">@null</item>
<item name="android:dividerHeight">0dp</item>
@ -334,6 +333,18 @@
<item name="titleTextStyle">@style/TextSecure.TitleTextStyle.Conversation</item>
</style>
<style name="Widget.Signal.ArcProgressBar" parent="">
<item name="android:layout_width">187dp</item>
<item name="android:layout_height">187dp</item>
<item name="arcBackgroundColor">?insight_progress_background</item>
<item name="arcForegroundColor">@color/signal_primary</item>
<item name="arcProgress">0.0</item>
<item name="arcStartAngle">120</item>
<item name="arcSweepAngle">300</item>
<item name="arcWidth">8dp</item>
<item name="arcRoundedEnds">true</item>
</style>
<declare-styleable name="CameraButtonView">
<attr name="imageCaptureSize" format="dimension" />
<attr name="recordSize" format="dimension" />
@ -362,4 +373,14 @@
<attr format="boolean" name="pinchToZoomEnabled"/>
</declare-styleable>
<declare-styleable name="ArcProgressBar">
<attr name="arcWidth" format="dimension" />
<attr name="arcBackgroundColor" format="color" />
<attr name="arcForegroundColor" format="color" />
<attr name="arcRoundedEnds" format="boolean" />
<attr name="arcStartAngle" format="float" />
<attr name="arcSweepAngle" format="float" />
<attr name="arcProgress" format="float" />
</declare-styleable>
</resources>

View File

@ -45,4 +45,46 @@
<item name="android:textColor">@color/core_white</item>
</style>
<style name="TextAppearance.Signal.Headline.Insights" parent="">
<item name="android:textStyle">bold</item>
<item name="android:textSize">28sp</item>
<item name="android:textColor">?title_text_color_primary</item>
</style>
<style name="TextAppearance.Signal.SubHead.Insights" parent="">
<item name="android:textStyle">bold</item>
<item name="android:textSize">20sp</item>
<item name="android:textColor">?title_text_color_primary</item>
</style>
<style name="TextAppearance.Signal.Headline.Insights.Modal.Percent" parent="TextAppearance.Signal.Headline.Insights">
<item name="android:textSize">@dimen/insights_modal_percent_text_size</item>
</style>
<style name="TextAppearance.Signal.SubHead.Insights.Modal.Percent" parent="TextAppearance.Signal.SubHead.Insights">
<item name="android:textSize">@dimen/insights_modal_percent_sign_text_size</item>
</style>
<style name="TextAppearance.Signal.Title.Insights" parent="">
<item name="android:textSize">16sp</item>
<item name="android:textColor">?insight_title</item>
</style>
<style name="TextAppearance.Signal.Body.Insights" parent="">
<item name="android:textColor">?title_text_color_secondary</item>
<item name="android:textSize">14sp</item>
</style>
<style name="TextAppearance.Signal.Body2.Insights" parent="">
<item name="android:textColor">?insight_body_2</item>
<item name="android:textSize">13sp</item>
</style>
<style name="TextAppearance.Signal.Caption.Insights" parent="">
<item name="android:textColor">@color/core_white</item>
<item name="android:textSize">16sp</item>
<item name="android:textAllCaps">true</item>
<item name="android:textStyle">bold</item>
</style>
</resources>

View File

@ -149,6 +149,13 @@
<item name="icon_tint">@color/core_grey_75</item>
<item name="icon_tint_dark">@color/core_grey_15</item>
<item name="insight_modal_background">@drawable/insights_modal_background</item>
<item name="insight_modal_button_background">@color/core_grey_10</item>
<item name="insight_title">@color/core_grey_90</item>
<item name="insight_body_2">@color/core_grey_60</item>
<item name="insight_dashboard_bottom_bar_background">@color/core_grey_02</item>
<item name="insight_progress_background">@color/core_grey_15</item>
<item name="search_view_style">@style/Signal.SearchView</item>
<item name="search_view_style_dark">@style/Signal.SearchView.Dark</item>
@ -365,6 +372,13 @@
<item name="icon_tint">@color/core_grey_15</item>
<item name="icon_tint_dark">?icon_tint</item>
<item name="insight_modal_background">@drawable/insights_modal_background_dark</item>
<item name="insight_modal_button_background">@color/core_grey_60</item>
<item name="insight_title">@color/core_grey_25</item>
<item name="insight_body_2">@color/core_grey_25</item>
<item name="insight_progress_background">@color/core_grey_60</item>
<item name="insight_dashboard_bottom_bar_background">@color/core_grey_80</item>
<item name="search_view_style">@style/Signal.SearchView</item>
<item name="search_view_style_dark">@style/Signal.SearchView.Dark</item>
@ -575,6 +589,9 @@
<item name="android:windowBackground">@drawable/permission_rationale_dialog_corners</item>
</style>
<style name="Theme.Signal.Insights.Modal" parent="@style/Theme.AppCompat.Dialog.MinWidth">
</style>
<style name="TextSecure.MediaSendProgressDialog" parent="@android:style/Theme.Dialog">
<item name="android:background">@color/core_grey_95</item>
</style>

View File

@ -25,10 +25,6 @@ import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import android.text.TextUtils;
import android.view.Menu;
import android.view.MenuInflater;
@ -38,6 +34,10 @@ import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import androidx.appcompat.widget.TooltipCompat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.color.MaterialColor;
@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.lock.RegistrationLockDialog;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
@ -131,6 +132,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
inflater.inflate(R.menu.text_secure_normal, menu);
menu.findItem(R.id.menu_insights).setVisible(TextSecurePreferences.isSmsEnabled(this));
menu.findItem(R.id.menu_clear_passphrase).setVisible(!TextSecurePreferences.isPasswordDisabled(this));
super.onPrepareOptionsMenu(menu);
@ -212,6 +214,7 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
case R.id.menu_clear_passphrase: handleClearPassphrase(); return true;
case R.id.menu_mark_all_read: handleMarkAllRead(); return true;
case R.id.menu_invite: handleInvite(); return true;
case R.id.menu_insights: handleInsights(); return true;
case R.id.menu_help: handleHelp(); return true;
}
@ -300,6 +303,10 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit
startActivity(new Intent(this, InviteActivity.class));
}
private void handleInsights() {
InsightsLauncher.showInsightsDashboard(getSupportFragmentManager());
}
private void handleHelp() {
try {
startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://support.signal.org")));

View File

@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.loaders.ConversationListLoader;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
@ -84,6 +85,7 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
@ -188,6 +190,10 @@ public class ConversationListFragment extends Fragment
updateReminders(true);
list.getAdapter().notifyDataSetChanged();
EventBus.getDefault().register(this);
if (TextSecurePreferences.isSmsEnabled(requireContext())) {
InsightsLauncher.showInsightsModal(requireContext(), requireFragmentManager());
}
}
@Override

View File

@ -5,15 +5,12 @@ import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.AsyncTask;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import androidx.annotation.AnimRes;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import androidx.appcompat.app.AlertDialog;
import android.view.View;
@ -237,7 +234,7 @@ public class InviteActivity extends PassphraseRequiredActionBarActivity implemen
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null);
if (recipient.getContactUri() != null) {
DatabaseFactory.getRecipientDatabase(context).setSeenInviteReminder(recipient.getId(), true);
DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId());
}
}

View File

@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.Util;
public class ArcProgressBar extends View {
private static final int DEFAULT_WIDTH = 10;
private static final float DEFAULT_PROGRESS = 0f;
private static final int DEFAULT_BACKGROUND_COLOR = 0xFF000000;
private static final int DEFAULT_FOREGROUND_COLOR = 0xFFFFFFFF;
private static final float DEFAULT_START_ANGLE = 0f;
private static final float DEFAULT_SWEEP_ANGLE = 360f;
private static final boolean DEFAULT_ROUNDED_ENDS = true;
private static final String SUPER = "arcprogressbar.super";
private static final String PROGRESS = "arcprogressbar.progress";
private float progress;
private final float width;
private final RectF arcRect = new RectF();
private final Paint arcBackgroundPaint;
private final Paint arcForegroundPaint;
private final float arcStartAngle;
private final float arcSweepAngle;
public ArcProgressBar(@NonNull Context context) {
this(context, null);
}
public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public ArcProgressBar(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs, R.styleable.ArcProgressBar, defStyleAttr, 0);
width = attributes.getDimensionPixelSize(R.styleable.ArcProgressBar_arcWidth, DEFAULT_WIDTH);
progress = attributes.getFloat(R.styleable.ArcProgressBar_arcProgress, DEFAULT_PROGRESS);
arcBackgroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcBackgroundColor, DEFAULT_BACKGROUND_COLOR));
arcForegroundPaint = createPaint(width, attributes.getColor(R.styleable.ArcProgressBar_arcForegroundColor, DEFAULT_FOREGROUND_COLOR));
arcStartAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcStartAngle, DEFAULT_START_ANGLE);
arcSweepAngle = attributes.getFloat(R.styleable.ArcProgressBar_arcSweepAngle, DEFAULT_SWEEP_ANGLE);
if (attributes.getBoolean(R.styleable.ArcProgressBar_arcRoundedEnds, DEFAULT_ROUNDED_ENDS)) {
arcForegroundPaint.setStrokeCap(Paint.Cap.ROUND);
if (arcSweepAngle <= 360f) {
arcBackgroundPaint.setStrokeCap(Paint.Cap.ROUND);
}
}
attributes.recycle();
}
private static Paint createPaint(float width, @ColorInt int color) {
Paint paint = new Paint();
paint.setStrokeWidth(width);
paint.setStyle(Paint.Style.STROKE);
paint.setAntiAlias(true);
paint.setColor(color);
return paint;
}
public void setProgress(float progress) {
if (this.progress != progress) {
this.progress = progress;
invalidate();
}
}
@Override
protected @Nullable Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(SUPER, superState);
bundle.putFloat(PROGRESS, progress);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state.getClass() != Bundle.class) throw new IllegalStateException("Expected");
Bundle restoreState = (Bundle) state;
Parcelable superState = restoreState.getParcelable(SUPER);
super.onRestoreInstanceState(superState);
progress = restoreState.getLong(PROGRESS);
}
@Override
protected void onDraw(Canvas canvas) {
float halfWidth = width / 2f;
arcRect.set(0 + halfWidth,
0 + halfWidth,
getWidth() - halfWidth,
getHeight() - halfWidth);
canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle, false, arcBackgroundPaint);
canvas.drawArc(arcRect, arcStartAngle, arcSweepAngle * Util.clamp(progress, 0f, 1f), false, arcForegroundPaint);
}
}

View File

@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
public final class FirstInviteReminder extends Reminder {
public FirstInviteReminder(final @NonNull Context context,
final @NonNull Recipient recipient,
final int percentIncrease) {
super(context.getString(R.string.FirstInviteReminder__title),
context.getString(R.string.FirstInviteReminder__description, percentIncrease));
addAction(new Action(context.getString(R.string.InsightsReminder__invite), R.id.reminder_action_invite));
addAction(new Action(context.getString(R.string.InsightsReminder__view_insights), R.id.reminder_action_view_insights));
}
}

View File

@ -1,28 +0,0 @@
package org.thoughtcrime.securesms.components.reminder;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.AsyncTask;
import androidx.annotation.NonNull;
import android.view.View;
import android.view.View.OnClickListener;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
public class InviteReminder extends Reminder {
@SuppressLint("StaticFieldLeak")
public InviteReminder(final @NonNull Context context,
final @NonNull Recipient recipient)
{
super(context.getString(R.string.reminder_header_invite_title),
context.getString(R.string.reminder_header_invite_text, recipient.toShortString(context)));
setDismissListener(v -> SignalExecutors.BOUNDED.execute(() -> {
DatabaseFactory.getRecipientDatabase(context).setSeenInviteReminder(recipient.getId(), true);
}));
}
}

View File

@ -1,9 +1,15 @@
package org.thoughtcrime.securesms.components.reminder;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.View;
import android.view.View.OnClickListener;
import java.util.LinkedList;
import java.util.List;
public abstract class Reminder {
private CharSequence title;
private CharSequence text;
@ -11,6 +17,8 @@ public abstract class Reminder {
private OnClickListener okListener;
private OnClickListener dismissListener;
private final List<Action> actions = new LinkedList<>();
public Reminder(@Nullable CharSequence title,
@NonNull CharSequence text)
{
@ -50,8 +58,37 @@ public abstract class Reminder {
return Importance.NORMAL;
}
public void addAction(@NonNull Action action) {
actions.add(action);
}
public List<Action> getActions() {
return actions;
}
public int getProgress() {
return -1;
}
public enum Importance {
NORMAL, ERROR
}
public final class Action {
private final CharSequence title;
private final int actionId;
public Action(CharSequence title, @IdRes int actionId) {
this.title = title;
this.actionId = actionId;
}
CharSequence getTitle() {
return title;
}
int getActionId() {
return actionId;
}
}
}

View File

@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.components.reminder;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import java.util.Collections;
import java.util.List;
final class ReminderActionsAdapter extends RecyclerView.Adapter<ReminderActionsAdapter.ActionViewHolder> {
private final List<Reminder.Action> actions;
private final ReminderView.OnActionClickListener actionClickListener;
ReminderActionsAdapter(List<Reminder.Action> actions, ReminderView.OnActionClickListener actionClickListener) {
this.actions = Collections.unmodifiableList(actions);
this.actionClickListener = actionClickListener;
}
@NonNull
@Override
public ActionViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ActionViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reminder_action_button, parent, false));
}
@Override
public void onBindViewHolder(@NonNull ActionViewHolder holder, int position) {
final Reminder.Action action = actions.get(position);
((Button) holder.itemView).setText(action.getTitle());
holder.itemView.setOnClickListener(v -> {
if (holder.getAdapterPosition() == RecyclerView.NO_POSITION) return;
actionClickListener.onActionClick(action.getActionId());
});
}
@Override
public int getItemCount() {
return actions.size();
}
final class ActionViewHolder extends RecyclerView.ViewHolder {
ActionViewHolder(@NonNull View itemView) {
super(itemView);
}
}
}

View File

@ -8,22 +8,35 @@ import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.Space;
import android.widget.TextView;
import androidx.annotation.IdRes;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
/**
* View to display actionable reminders to the user
*/
public class ReminderView extends LinearLayout {
private ViewGroup container;
private ImageButton closeButton;
private TextView title;
private TextView text;
private OnDismissListener dismissListener;
public final class ReminderView extends FrameLayout {
private ProgressBar progressBar;
private TextView progressText;
private ViewGroup container;
private ImageButton closeButton;
private TextView title;
private TextView text;
private OnDismissListener dismissListener;
private Space space;
private RecyclerView actionsRecycler;
private OnActionClickListener actionClickListener;
public ReminderView(Context context) {
super(context);
@ -43,19 +56,25 @@ public class ReminderView extends LinearLayout {
private void initialize() {
LayoutInflater.from(getContext()).inflate(R.layout.reminder_header, this, true);
container = ViewUtil.findById(this, R.id.container);
closeButton = ViewUtil.findById(this, R.id.cancel);
title = ViewUtil.findById(this, R.id.reminder_title);
text = ViewUtil.findById(this, R.id.reminder_text);
progressBar = ViewUtil.findById(this, R.id.reminder_progress);
progressText = ViewUtil.findById(this, R.id.reminder_progress_text);
container = ViewUtil.findById(this, R.id.container);
closeButton = ViewUtil.findById(this, R.id.cancel);
title = ViewUtil.findById(this, R.id.reminder_title);
text = ViewUtil.findById(this, R.id.reminder_text);
space = ViewUtil.findById(this, R.id.reminder_space);
actionsRecycler = ViewUtil.findById(this, R.id.reminder_actions);
}
public void showReminder(final Reminder reminder) {
if (!TextUtils.isEmpty(reminder.getTitle())) {
title.setText(reminder.getTitle());
title.setVisibility(VISIBLE);
space.setVisibility(GONE);
} else {
title.setText("");
title.setVisibility(GONE);
space.setVisibility(VISIBLE);
}
text.setText(reminder.getText());
container.setBackgroundResource(reminder.getImportance() == Reminder.Importance.ERROR ? R.drawable.reminder_background_error
@ -73,13 +92,40 @@ public class ReminderView extends LinearLayout {
}
});
int progress = reminder.getProgress();
if (progress != -1) {
progressBar.setProgress(progress);
progressBar.setVisibility(VISIBLE);
progressText.setText(getContext().getString(R.string.reminder_header_progress, progress));
progressText.setVisibility(VISIBLE);
} else {
progressBar.setVisibility(GONE);
progressText.setVisibility(GONE);
}
List<Reminder.Action> actions = reminder.getActions();
if (actions.isEmpty()) {
actionsRecycler.setVisibility(GONE);
} else {
actionsRecycler.setVisibility(VISIBLE);
actionsRecycler.setAdapter(new ReminderActionsAdapter(actions, this::handleActionClicked));
}
container.setVisibility(View.VISIBLE);
}
private void handleActionClicked(@IdRes int actionId) {
if (actionClickListener != null) actionClickListener.onActionClick(actionId);
}
public void setOnDismissListener(OnDismissListener dismissListener) {
this.dismissListener = dismissListener;
}
public void setOnActionClickListener(@Nullable OnActionClickListener actionClickListener) {
this.actionClickListener = actionClickListener;
}
public void requestDismiss() {
closeButton.performClick();
}
@ -91,4 +137,8 @@ public class ReminderView extends LinearLayout {
public interface OnDismissListener {
void onDismiss();
}
public interface OnActionClickListener {
void onActionClick(@IdRes int actionId);
}
}

View File

@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
public final class SecondInviteReminder extends Reminder {
private final int progress;
public SecondInviteReminder(final @NonNull Context context,
final @NonNull Recipient recipient,
final int percent)
{
super(context.getString(R.string.SecondInviteReminder__title),
context.getString(R.string.SecondInviteReminder__description, recipient.getDisplayName(context)));
this.progress = percent;
addAction(new Action(context.getString(R.string.InsightsReminder__invite), R.id.reminder_action_invite));
addAction(new Action(context.getString(R.string.InsightsReminder__view_insights), R.id.reminder_action_view_insights));
}
@Override
public int getProgress() {
return progress;
}
}

View File

@ -32,13 +32,11 @@ import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.graphics.PorterDuff;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.hardware.Camera;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.os.Vibrator;
import android.provider.Browser;
import android.provider.ContactsContract;
@ -62,16 +60,15 @@ import android.widget.ImageButton;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SearchView;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.core.content.pm.ShortcutInfoCompat;
import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.core.view.MenuItemCompat;
import androidx.lifecycle.ViewModelProviders;
@ -100,7 +97,6 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.TombstoneAttachment;
import org.thoughtcrime.securesms.audio.AudioRecorder;
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.components.AttachmentTypeSelector;
@ -120,12 +116,13 @@ import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
import org.thoughtcrime.securesms.components.identity.UnverifiedSendDialog;
import org.thoughtcrime.securesms.components.location.SignalPlace;
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
import org.thoughtcrime.securesms.components.reminder.InviteReminder;
import org.thoughtcrime.securesms.components.reminder.Reminder;
import org.thoughtcrime.securesms.components.reminder.ReminderView;
import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.contacts.ContactAccessor;
import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
@ -152,6 +149,9 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.invites.InviteReminderModel;
import org.thoughtcrime.securesms.invites.InviteReminderRepository;
import org.thoughtcrime.securesms.jobs.MultiDeviceBlockedUpdateJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
@ -210,7 +210,6 @@ import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ExpirationUtil;
@ -220,7 +219,6 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.TextSecurePreferences.MediaKeyboardMode;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
@ -322,6 +320,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
private ConversationStickerViewModel stickerViewModel;
private InviteReminderModel inviteReminderModel;
private LiveRecipient recipient;
private long threadId;
@ -399,6 +398,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
});
}
});
initializeInsightObserver();
}
@Override
@ -816,7 +816,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEvent(ReminderUpdateEvent event) {
updateReminders(recipient.get().hasSeenInviteReminder());
updateReminders();
}
@Override
@ -1423,12 +1423,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private void onSecurityUpdated() {
Log.i(TAG, "onSecurityUpdated()");
updateReminders(recipient.get().hasSeenInviteReminder());
updateReminders();
updateDefaultSubscriptionId(recipient.get().getDefaultSubscriptionId());
}
protected void updateReminders(boolean seenInvite) {
Log.i(TAG, "updateReminders(" + seenInvite + ")");
private void initializeInsightObserver() {
inviteReminderModel = new InviteReminderModel(this, new InviteReminderRepository(this));
inviteReminderModel.loadReminder(recipient, this::updateReminders);
}
protected void updateReminders() {
Optional<Reminder> inviteReminder = inviteReminderModel.getReminder();
if (UnauthorizedReminder.isEligible(this)) {
reminderView.get().showReminder(new UnauthorizedReminder(this));
@ -1439,21 +1444,31 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
reminderView.get().showReminder(new ServiceOutageReminder(this));
} else if (TextSecurePreferences.isPushRegistered(this) &&
TextSecurePreferences.isShowInviteReminders(this) &&
!isSecureText &&
!seenInvite &&
!recipient.get().isGroup())
{
InviteReminder reminder = new InviteReminder(this, recipient.get());
reminder.setOkListener(v -> {
handleInviteLink();
reminderView.get().requestDismiss();
});
reminderView.get().showReminder(reminder);
!isSecureText &&
inviteReminder.isPresent() &&
!recipient.get().isGroup()) {
reminderView.get().setOnActionClickListener(this::handleReminderAction);
reminderView.get().setOnDismissListener(() -> inviteReminderModel.dismissReminder());
reminderView.get().showReminder(inviteReminder.get());
} else if (reminderView.resolved()) {
reminderView.get().hide();
}
}
private void handleReminderAction(@IdRes int reminderActionId) {
switch (reminderActionId) {
case R.id.reminder_action_invite:
handleInviteLink();
reminderView.get().requestDismiss();
break;
case R.id.reminder_action_view_insights:
InsightsLauncher.showInsightsDashboard(getSupportFragmentManager());
break;
default:
throw new IllegalArgumentException("Unknown ID: " + reminderActionId);
}
}
private void updateDefaultSubscriptionId(Optional<Integer> defaultSubscriptionId) {
Log.i(TAG, "updateDefaultSubscriptionId(" + defaultSubscriptionId.orNull() + ")");
sendButton.setDefaultSubscriptionId(defaultSubscriptionId);
@ -1742,7 +1757,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
setBlockedUserState(recipient, isSecureText, isDefaultSms);
setActionBarColor(recipient.getColor());
setGroupShareProfileReminder(recipient);
updateReminders(recipient.hasSeenInviteReminder());
updateReminders();
updateDefaultSubscriptionId(recipient.getDefaultSubscriptionId());
initializeSecurity(isSecureText, isDefaultSms);

View File

@ -116,7 +116,7 @@ public class ConversationPopupActivity extends ConversationActivity {
}
@Override
protected void updateReminders(boolean seenInvite) {
protected void updateReminders() {
if (reminderView.resolved()) {
reminderView.get().setVisibility(View.GONE);
}

View File

@ -7,6 +7,8 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.documents.Document;
@ -16,12 +18,14 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.IdentityKey;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;
public abstract class MessagingDatabase extends Database implements MmsSmsColumns {
@ -32,6 +36,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
}
protected abstract String getTableName();
protected abstract String getTypeField();
protected abstract String getDateSentColumnName();
public abstract void markExpireStarted(long messageId);
public abstract void markExpireStarted(long messageId, long startTime);
@ -39,6 +45,61 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markAsSent(long messageId, boolean secure);
public abstract void markUnidentified(long messageId, boolean unidentified);
final int getInsecureMessagesSentForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[]{"COUNT(*)"};
String query = THREAD_ID + " = ? AND " + getOutgoingInsecureMessageClause() + " AND " + getDateSentColumnName() + " > ?";
String[] args = new String[]{String.valueOf(threadId), String.valueOf(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7))};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
final int getInsecureMessageCountForRecipients(List<RecipientId> recipients) {
return getMessageCountForRecipientsAndType(recipients, getOutgoingInsecureMessageClause());
}
final int getSecureMessageCountForRecipients(List<RecipientId> recipients) {
return getMessageCountForRecipientsAndType(recipients, getOutgoingSecureMessageClause());
}
private int getMessageCountForRecipientsAndType(List<RecipientId> recipients, String typeClause) {
if (recipients.size() == 0) return 0;
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String placeholders = Util.join(Stream.of(recipients).map(r -> "?").toList(), ",");
String[] projection = new String[] {"COUNT(*)"};
String query = RECIPIENT_ID + " IN ( " + placeholders + " ) AND " + typeClause + " AND " + getDateSentColumnName() + " > ?";
String[] args = new String[recipients.size() + 1];
for (int i = 0; i < recipients.size(); i++) {
args[i] = recipients.get(i).serialize();
}
args[args.length - 1] = String.valueOf(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7));
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
return cursor.getInt(0);
} else {
return 0;
}
}
}
private String getOutgoingInsecureMessageClause() {
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + getTypeField() + " & " + Types.SECURE_MESSAGE_BIT + ")";
}
private String getOutgoingSecureMessageClause() {
return "(" + getTypeField() + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + getTypeField() + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
}
public void addMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) {
try {
addToDocument(messageId, MISMATCHED_IDENTITIES,

View File

@ -35,7 +35,6 @@ import net.sqlcipher.database.SQLiteDatabase;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
@ -81,6 +80,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import static org.thoughtcrime.securesms.contactshare.Contact.Avatar;
@ -182,6 +182,9 @@ public class MmsDatabase extends MessagingDatabase {
private static final String RAW_ID_WHERE = TABLE_NAME + "._id = ?";
private static final String OUTGOING_INSECURE_MESSAGES_CLAUSE = "(" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + MESSAGE_BOX + " & " + Types.SECURE_MESSAGE_BIT + ")";
private static final String OUTGOING_SECURE_MESSAGES_CLAUSE = "(" + MESSAGE_BOX + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + MESSAGE_BOX + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
private final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("MmsDelivery");
private final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache("MmsRead");
@ -194,6 +197,16 @@ public class MmsDatabase extends MessagingDatabase {
return TABLE_NAME;
}
@Override
protected String getDateSentColumnName() {
return DATE_SENT;
}
@Override
protected String getTypeField() {
return MESSAGE_BOX;
}
public int getMessageCountForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = null;

View File

@ -31,6 +31,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class MmsSmsDatabase extends Database {
@ -155,6 +156,27 @@ public class MmsSmsDatabase extends Database {
return count;
}
public int getInsecureSentCount(long threadId) {
int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessagesSentForThread(threadId);
count += DatabaseFactory.getMmsDatabase(context).getInsecureMessagesSentForThread(threadId);
return count;
}
public int getInsecureMessageCountForRecipients(List<RecipientId> recipients) {
int count = DatabaseFactory.getSmsDatabase(context).getInsecureMessageCountForRecipients(recipients);
count += DatabaseFactory.getMmsDatabase(context).getInsecureMessageCountForRecipients(recipients);
return count;
}
public int getSecureMessageCountForRecipients(List<RecipientId> recipients) {
int count = DatabaseFactory.getSmsDatabase(context).getSecureMessageCountForRecipients(recipients);
count += DatabaseFactory.getMmsDatabase(context).getSecureMessageCountForRecipients(recipients);
return count;
}
public void incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) {
DatabaseFactory.getSmsDatabase(context).incrementReceiptCount(syncMessageId, true, false);
DatabaseFactory.getMmsDatabase(context).incrementReceiptCount(syncMessageId, timestamp, true, false);

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.database;
import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@ -140,6 +141,28 @@ public class RecipientDatabase extends Database {
}
}
public enum InsightsBannerTier {
NO_TIER(0), TIER_ONE(1), TIER_TWO(2);
private final int id;
InsightsBannerTier(int id) {
this.id = id;
}
public int getId() {
return id;
}
public boolean seen(InsightsBannerTier tier) {
return tier.getId() <= id;
}
public static InsightsBannerTier fromId(int id) {
return values()[id];
}
}
public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
UUID + " TEXT UNIQUE DEFAULT NULL, " +
@ -154,7 +177,7 @@ public class RecipientDatabase extends Database {
NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " +
MUTE_UNTIL + " INTEGER DEFAULT 0, " +
COLOR + " TEXT DEFAULT NULL, " +
SEEN_INVITE_REMINDER + " INTEGER DEFAULT 0, " +
SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " +
DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " +
MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " +
REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " +
@ -171,6 +194,17 @@ public class RecipientDatabase extends Database {
FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " +
UUID_SUPPORTED + " INTEGER DEFAULT 0);";
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
" FROM " + TABLE_NAME +
" INNER JOIN " + ThreadDatabase.TABLE_NAME +
" ON " + TABLE_NAME + "." + ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID +
" WHERE " +
TABLE_NAME + "." + GROUP_ID + " IS NULL AND " +
TABLE_NAME + "." + REGISTERED + " = " + RegisteredState.NOT_REGISTERED.id + " AND " +
TABLE_NAME + "." + SEEN_INVITE_REMINDER + " < " + InsightsBannerTier.TIER_TWO.id + " AND " +
ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.HAS_SENT +
" ORDER BY " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC";
public RecipientDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
}
@ -264,7 +298,7 @@ public class RecipientDatabase extends Database {
int callVibrateState = cursor.getInt(cursor.getColumnIndexOrThrow(CALL_VIBRATE));
long muteUntil = cursor.getLong(cursor.getColumnIndexOrThrow(MUTE_UNTIL));
String serializedColor = cursor.getString(cursor.getColumnIndexOrThrow(COLOR));
boolean seenInviteReminder = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_INVITE_REMINDER)) == 1;
int insightsBannerTier = cursor.getInt(cursor.getColumnIndexOrThrow(SEEN_INVITE_REMINDER));
int defaultSubscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(DEFAULT_SUBSCRIPTION_ID));
int expireMessages = cursor.getInt(cursor.getColumnIndexOrThrow(MESSAGE_EXPIRATION_TIME));
int registeredState = cursor.getInt(cursor.getColumnIndexOrThrow(REGISTERED));
@ -304,14 +338,13 @@ public class RecipientDatabase extends Database {
VibrateState.fromId(messageVibrateState),
VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone),
color, seenInviteReminder,
defaultSubscriptionId, expireMessages,
color, defaultSubscriptionId, expireMessages,
RegisteredState.fromId(registeredState),
profileKey, systemDisplayName, systemContactPhoto,
systemPhoneLabel, systemContactUri,
signalProfileName, signalProfileAvatar, profileSharing,
notificationChannel, UnidentifiedAccessMode.fromMode(unidentifiedAccessMode),
forceSmsSelection, uuidSupported);
forceSmsSelection, uuidSupported, InsightsBannerTier.fromId(insightsBannerTier));
}
public BulkOperationsHandle resetAllSystemContactInfo() {
@ -392,10 +425,26 @@ public class RecipientDatabase extends Database {
Recipient.live(id).refresh();
}
public void setSeenInviteReminder(@NonNull RecipientId id, @SuppressWarnings("SameParameterValue") boolean seen) {
ContentValues values = new ContentValues(1);
values.put(SEEN_INVITE_REMINDER, seen ? 1 : 0);
update(id, values);
public void setSeenFirstInviteReminder(@NonNull RecipientId id) {
setInsightsBannerTier(id, InsightsBannerTier.TIER_ONE);
}
public void setSeenSecondInviteReminder(@NonNull RecipientId id) {
setInsightsBannerTier(id, InsightsBannerTier.TIER_TWO);
}
public void setHasSentInvite(@NonNull RecipientId id) {
setSeenSecondInviteReminder(id);
}
private void setInsightsBannerTier(@NonNull RecipientId id, @NonNull InsightsBannerTier insightsBannerTier) {
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues(1);
String query = ID + " = ? AND " + SEEN_INVITE_REMINDER + " < ?";
String[] args = new String[]{ id.serialize(), String.valueOf(insightsBannerTier) };
values.put(SEEN_INVITE_REMINDER, insightsBannerTier.id);
database.update(TABLE_NAME, values, query, args);
Recipient.live(id).refresh();
}
@ -563,7 +612,45 @@ public class RecipientDatabase extends Database {
}
}
public List<RecipientId> getRegistered() {
public @NonNull List<RecipientId> getUninvitedRecipientsForInsights() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
try (Cursor cursor = db.rawQuery(INSIGHTS_INVITEE_LIST, null)) {
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
}
}
return results;
}
public @NonNull List<RecipientId> getNotRegisteredForInsights() {
return getRecipientsForInsights(REGISTERED + " = ?", new String[]{String.valueOf(RegisteredState.NOT_REGISTERED.id)});
}
public @NonNull List<RecipientId> getRegisteredForInsights() {
final String selfId = Recipient.self().getId().serialize();
final String query = REGISTERED + " = ? AND " + ID + " != ?";
final String[] args = new String[]{String.valueOf(RegisteredState.REGISTERED.id), selfId};
return getRecipientsForInsights(query, args);
}
private @NonNull List<RecipientId> getRecipientsForInsights(@NonNull String query, @NonNull String[] args) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
try (Cursor cursor = db.query(TABLE_NAME, ID_PROJECTION, query + " AND " + GROUP_ID + " IS NULL", args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
results.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ID))));
}
}
return results;
}
public @NonNull List<RecipientId> getRegistered() {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<RecipientId> results = new LinkedList<>();
@ -776,7 +863,6 @@ public class RecipientDatabase extends Database {
private final Uri messageRingtone;
private final Uri callRingtone;
private final MaterialColor color;
private final boolean seenInviteReminder;
private final int defaultSubscriptionId;
private final int expireMessages;
private final RegisteredState registered;
@ -792,6 +878,7 @@ public class RecipientDatabase extends Database {
private final UnidentifiedAccessMode unidentifiedAccessMode;
private final boolean forceSmsSelection;
private final boolean uuidSupported;
private final InsightsBannerTier insightsBannerTier;
RecipientSettings(@NonNull RecipientId id,
@Nullable UUID uuid,
@ -804,7 +891,6 @@ public class RecipientDatabase extends Database {
@Nullable Uri messageRingtone,
@Nullable Uri callRingtone,
@Nullable MaterialColor color,
boolean seenInviteReminder,
int defaultSubscriptionId,
int expireMessages,
@NonNull RegisteredState registered,
@ -819,7 +905,8 @@ public class RecipientDatabase extends Database {
@Nullable String notificationChannel,
@NonNull UnidentifiedAccessMode unidentifiedAccessMode,
boolean forceSmsSelection,
boolean uuidSupported)
boolean uuidSupported,
@NonNull InsightsBannerTier insightsBannerTier)
{
this.id = id;
this.uuid = uuid;
@ -833,7 +920,6 @@ public class RecipientDatabase extends Database {
this.messageRingtone = messageRingtone;
this.callRingtone = callRingtone;
this.color = color;
this.seenInviteReminder = seenInviteReminder;
this.defaultSubscriptionId = defaultSubscriptionId;
this.expireMessages = expireMessages;
this.registered = registered;
@ -849,6 +935,7 @@ public class RecipientDatabase extends Database {
this.unidentifiedAccessMode = unidentifiedAccessMode;
this.forceSmsSelection = forceSmsSelection;
this.uuidSupported = uuidSupported;
this.insightsBannerTier = insightsBannerTier;
}
public RecipientId getId() {
@ -899,8 +986,8 @@ public class RecipientDatabase extends Database {
return callRingtone;
}
public boolean hasSeenInviteReminder() {
return seenInviteReminder;
public @NonNull InsightsBannerTier getInsightsBannerTier() {
return insightsBannerTier;
}
public Optional<Integer> getDefaultSubscriptionId() {

View File

@ -29,7 +29,6 @@ import com.annimon.stream.Stream;
import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteStatement;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
@ -45,6 +44,7 @@ import org.thoughtcrime.securesms.sms.IncomingTextMessage;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
@ -53,6 +53,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Database for storage of SMS messages.
@ -102,6 +103,9 @@ public class SmsDatabase extends MessagingDatabase {
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED
};
private final String OUTGOING_INSECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + Types.SECURE_MESSAGE_BIT + ")";
private final String OUTGOING_SECURE_MESSAGE_CLAUSE = "(" + TYPE + " & " + Types.BASE_TYPE_MASK + ") = " + Types.BASE_SENT_TYPE + " AND (" + TYPE + " & " + (Types.SECURE_MESSAGE_BIT | Types.PUSH_MESSAGE_BIT) + ")";
private static final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("SmsDelivery");
private static final EarlyReceiptCache earlyReadReceiptCache = new EarlyReceiptCache("SmsRead");
@ -113,6 +117,16 @@ public class SmsDatabase extends MessagingDatabase {
return TABLE_NAME;
}
@Override
protected String getDateSentColumnName() {
return DATE_SENT;
}
@Override
protected String getTypeField() {
return TYPE;
}
private void updateTypeBitmask(long id, long maskOff, long maskOn) {
Log.i("MessageDatabase", "Updating ID: " + id + " to base type: " + maskOn);

View File

@ -79,7 +79,7 @@ public class ThreadDatabase extends Database {
public static final String READ_RECEIPT_COUNT = "read_receipt_count";
public static final String EXPIRES_IN = "expires_in";
public static final String LAST_SEEN = "last_seen";
private static final String HAS_SENT = "has_sent";
public static final String HAS_SENT = "has_sent";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " +

View File

@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.insights;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.view.animation.DecelerateInterpolator;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
final class InsightsAnimatorSetFactory {
private static final int PROGRESS_ANIMATION_DURATION = 800;
private static final int DETAILS_ANIMATION_DURATION = 200;
private static final int PERCENT_SECURE_ANIMATION_DURATION = 400;
private static final int LOTTIE_ANIMATION_DURATION = 1500;
private static final int ANIMATION_START_DELAY = PROGRESS_ANIMATION_DURATION - DETAILS_ANIMATION_DURATION;
private static final float PERCENT_SECURE_MAX_SCALE = 1.3f;
private InsightsAnimatorSetFactory() {
}
static AnimatorSet create(int insecurePercent,
@Nullable final UpdateListener progressUpdateListener,
@Nullable final UpdateListener detailsUpdateListener,
@Nullable final UpdateListener percentSecureListener,
@Nullable final UpdateListener lottieListener)
{
final int securePercent = 100 - insecurePercent;
final AnimatorSet animatorSet = new AnimatorSet();
final ValueAnimator[] animators = Stream.of(createProgressAnimator(securePercent, progressUpdateListener),
createDetailsAnimator(detailsUpdateListener),
createPercentSecureAnimator(percentSecureListener),
createLottieAnimator(lottieListener))
.filter(a -> a != null)
.toArray(ValueAnimator[]::new);
animatorSet.setInterpolator(new DecelerateInterpolator());
animatorSet.playTogether(animators);
return animatorSet;
}
private static @Nullable Animator createProgressAnimator(int securePercent, @Nullable UpdateListener updateListener) {
if (updateListener == null) return null;
final ValueAnimator progressAnimator = ValueAnimator.ofFloat(0, securePercent / 100f);
progressAnimator.setDuration(PROGRESS_ANIMATION_DURATION);
progressAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
return progressAnimator;
}
private static @Nullable Animator createDetailsAnimator(@Nullable UpdateListener updateListener) {
if (updateListener == null) return null;
final ValueAnimator detailsAnimator = ValueAnimator.ofFloat(0, 1f);
detailsAnimator.setDuration(DETAILS_ANIMATION_DURATION);
detailsAnimator.setStartDelay(ANIMATION_START_DELAY);
detailsAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
return detailsAnimator;
}
private static @Nullable Animator createPercentSecureAnimator(@Nullable UpdateListener updateListener) {
if (updateListener == null) return null;
final ValueAnimator percentSecureAnimator = ValueAnimator.ofFloat(1f, PERCENT_SECURE_MAX_SCALE, 1f);
percentSecureAnimator.setStartDelay(ANIMATION_START_DELAY);
percentSecureAnimator.setDuration(PERCENT_SECURE_ANIMATION_DURATION);
percentSecureAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
return percentSecureAnimator;
}
private static @Nullable Animator createLottieAnimator(@Nullable UpdateListener updateListener) {
if (updateListener == null) return null;
final ValueAnimator lottieAnimator = ValueAnimator.ofFloat(0, 1f);
lottieAnimator.setStartDelay(ANIMATION_START_DELAY);
lottieAnimator.setDuration(LOTTIE_ANIMATION_DURATION);
lottieAnimator.addUpdateListener(animation -> updateListener.onUpdate((float) animation.getAnimatedValue()));
return lottieAnimator;
}
interface UpdateListener {
void onUpdate(float value);
}
}

View File

@ -0,0 +1,269 @@
package org.thoughtcrime.securesms.insights;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.content.Intent;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
import com.airbnb.lottie.LottieAnimationView;
import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ArcProgressBar;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.List;
public final class InsightsDashboardDialogFragment extends DialogFragment {
private TextView securePercentage;
private ArcProgressBar progress;
private View progressContainer;
private TextView tagline;
private TextView encryptedMessages;
private TextView title;
private TextView description;
private RecyclerView insecureRecipients;
private TextView locallyGenerated;
private AvatarImageView avatarImageView;
private InsightsInsecureRecipientsAdapter adapter;
private LottieAnimationView lottieAnimationView;
private AnimatorSet animatorSet;
private Button startAConversation;
private Toolbar toolbar;
private InsightsDashboardViewModel viewModel;
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
requireFragmentManager().beginTransaction()
.detach(this)
.attach(this)
.commit();
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (ThemeUtil.isDarkTheme(requireActivity())) {
setStyle(STYLE_NO_FRAME, R.style.TextSecure_DarkTheme);
} else {
setStyle(STYLE_NO_FRAME, R.style.TextSecure_LightTheme);
}
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.insights_dashboard, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
securePercentage = view.findViewById(R.id.insights_dashboard_percent_secure);
progress = view.findViewById(R.id.insights_dashboard_progress);
progressContainer = view.findViewById(R.id.insights_dashboard_percent_container);
encryptedMessages = view.findViewById(R.id.insights_dashboard_encrypted_messages);
tagline = view.findViewById(R.id.insights_dashboard_tagline);
title = view.findViewById(R.id.insights_dashboard_make_signal_secure);
description = view.findViewById(R.id.insights_dashboard_invite_your_contacts);
insecureRecipients = view.findViewById(R.id.insights_dashboard_recycler);
locallyGenerated = view.findViewById(R.id.insights_dashboard_this_stat_was_generated_locally);
avatarImageView = view.findViewById(R.id.insights_dashboard_avatar);
startAConversation = view.findViewById(R.id.insights_dashboard_start_a_conversation);
lottieAnimationView = view.findViewById(R.id.insights_dashboard_lottie_animation);
toolbar = view.findViewById(R.id.insights_dashboard_toolbar);
setupStartAConversation();
setDashboardDetailsAlpha(0f);
setNotEnoughDataAlpha(0f);
setupToolbar();
setupRecycler();
initializeViewModel();
}
private void setupStartAConversation() {
startAConversation.setOnClickListener(v -> startActivity(new Intent(requireActivity(), NewConversationActivity.class)));
}
private void setDashboardDetailsAlpha(float alpha) {
tagline.setAlpha(alpha);
title.setAlpha(alpha);
description.setAlpha(alpha);
insecureRecipients.setAlpha(alpha);
locallyGenerated.setAlpha(alpha);
encryptedMessages.setAlpha(alpha);
}
private void setupToolbar() {
toolbar.setNavigationOnClickListener(v -> dismiss());
}
private void setupRecycler() {
adapter = new InsightsInsecureRecipientsAdapter(this::handleInviteRecipient);
insecureRecipients.setAdapter(adapter);
}
private void initializeViewModel() {
final InsightsDashboardViewModel.Repository repository = new InsightsRepository(requireContext());
final InsightsDashboardViewModel.Factory factory = new InsightsDashboardViewModel.Factory(repository);
viewModel = ViewModelProviders.of(this, factory).get(InsightsDashboardViewModel.class);
viewModel.getState().observe(this, state -> {
updateInsecurePercent(state.getData());
updateInsecureRecipients(state.getInsecureRecipients());
updateUserAvatar(state.getUserAvatar());
});
}
private void updateInsecurePercent(@Nullable InsightsData insightsData) {
if (insightsData == null) return;
if (insightsData.hasEnoughData()) {
setTitleAndDescriptionText(insightsData.getPercentInsecure());
animateProgress(insightsData.getPercentInsecure());
} else {
setNotEnoughDataText();
animateNotEnoughData();
}
}
private void animateProgress(int insecurePercent) {
startAConversation.setVisibility(View.GONE);
if (animatorSet == null) {
animatorSet = InsightsAnimatorSetFactory.create(insecurePercent,
this::setProgressPercentage,
this::setDashboardDetailsAlpha,
this::setPercentSecureScale,
insecurePercent == 0 ? this::setLottieProgress : null);
if (insecurePercent == 0) {
animatorSet.addListener(new ToolbarBackgroundColorAnimationListener());
}
animatorSet.start();
}
}
private void setProgressPercentage(float percent) {
securePercentage.setText(String.valueOf(Math.round(percent * 100)));
progress.setProgress(percent);
}
private void setPercentSecureScale(float scale) {
progressContainer.setScaleX(scale);
progressContainer.setScaleY(scale);
}
private void setLottieProgress(float progress) {
lottieAnimationView.setProgress(progress);
}
private void setTitleAndDescriptionText(int insecurePercent) {
startAConversation.setVisibility(View.GONE);
progressContainer.setVisibility(View.VISIBLE);
insecureRecipients.setVisibility(View.VISIBLE);
encryptedMessages.setText(R.string.InsightsDashboardFragment__encrypted_messages);
tagline.setText(getString(R.string.InsightsDashboardFragment__tagline, 100 - insecurePercent));
if (insecurePercent == 0) {
lottieAnimationView.setVisibility(View.VISIBLE);
title.setText(R.string.InsightsDashboardFragment__100_title);
description.setText(R.string.InsightsDashboardFragment__100_description);
} else {
lottieAnimationView.setVisibility(View.GONE);
title.setText(R.string.InsightsDashboardFragment__boost_your_signal);
description.setText(R.string.InsightsDashboardFragment__invite_your_contacts);
}
}
private void setNotEnoughDataText() {
startAConversation.setVisibility(View.VISIBLE);
progressContainer.setVisibility(View.INVISIBLE);
insecureRecipients.setVisibility(View.GONE);
encryptedMessages.setText(R.string.InsightsDashboardFragment__no_signal_yet);
tagline.setText(R.string.InsightsDashboardFragment__youre_just_getting_started);
}
private void animateNotEnoughData() {
if (animatorSet == null) {
animatorSet = InsightsAnimatorSetFactory.create(0, null, this::setNotEnoughDataAlpha, null, null);
animatorSet.start();
}
}
private void setNotEnoughDataAlpha(float alpha) {
encryptedMessages.setAlpha(alpha);
tagline.setAlpha(alpha);
startAConversation.setAlpha(alpha);
}
private void updateInsecureRecipients(@NonNull List<Recipient> recipients) {
adapter.updateData(recipients);
}
private void updateUserAvatar(@Nullable InsightsUserAvatar userAvatar) {
if (userAvatar == null) avatarImageView.setImageDrawable(null);
else userAvatar.load(avatarImageView);
}
private void handleInviteRecipient(final @NonNull Recipient recipient) {
new AlertDialog.Builder(requireContext())
.setTitle(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_invites, 1, 1))
.setMessage(getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url)))
.setPositiveButton(R.string.InsightsDashboardFragment__send, (dialog, which) -> viewModel.sendSmsInvite(recipient))
.setNegativeButton(R.string.InsightsDashboardFragment__cancel, (dialog, which) -> dialog.dismiss())
.show();
}
@Override
public void onDestroyView() {
if (animatorSet != null) {
animatorSet.cancel();
animatorSet = null;
}
super.onDestroyView();
}
private final class ToolbarBackgroundColorAnimationListener implements Animator.AnimatorListener {
@Override
public void onAnimationStart(Animator animation) {
toolbar.setBackgroundResource(R.color.transparent);
}
@Override
public void onAnimationEnd(Animator animation) {
toolbar.setBackgroundColor(ThemeUtil.getThemedColor(requireContext(), android.R.attr.windowBackground));
}
@Override
public void onAnimationCancel(Animator animation) {
toolbar.setBackgroundColor(ThemeUtil.getThemedColor(requireContext(), android.R.attr.windowBackground));
}
@Override
public void onAnimationRepeat(Animator animation) {
}
}
}

View File

@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.insights;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Collections;
import java.util.List;
final class InsightsDashboardState {
private final List<Recipient> insecureRecipients;
private final InsightsData insightsData;
private final InsightsUserAvatar userAvatar;
private InsightsDashboardState(@NonNull Builder builder) {
this.insecureRecipients = builder.insecureRecipients;
this.insightsData = builder.insightsData;
this.userAvatar = builder.userAvatar;
}
static @NonNull InsightsDashboardState.Builder builder() {
return new InsightsDashboardState.Builder();
}
@NonNull InsightsDashboardState.Builder buildUpon() {
return builder().withData(insightsData).withUserAvatar(userAvatar).withInsecureRecipients(insecureRecipients);
}
@NonNull List<Recipient> getInsecureRecipients() {
return insecureRecipients;
}
@Nullable InsightsUserAvatar getUserAvatar() {
return userAvatar;
}
@Nullable InsightsData getData() {
return insightsData;
}
static final class Builder {
private List<Recipient> insecureRecipients = Collections.emptyList();
private InsightsUserAvatar userAvatar;
private InsightsData insightsData;
private Builder() {
}
@NonNull Builder withInsecureRecipients(@NonNull List<Recipient> insecureRecipients) {
this.insecureRecipients = insecureRecipients;
return this;
}
@NonNull Builder withData(@NonNull InsightsData insightsData) {
this.insightsData = insightsData;
return this;
}
@NonNull Builder withUserAvatar(@NonNull InsightsUserAvatar userAvatar) {
this.userAvatar = userAvatar;
return this;
}
@NonNull InsightsDashboardState build() {
return new InsightsDashboardState(this);
}
}
}

View File

@ -0,0 +1,68 @@
package org.thoughtcrime.securesms.insights;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.List;
final class InsightsDashboardViewModel extends ViewModel {
private final MutableLiveData<InsightsDashboardState> internalState = new MutableLiveData<>(InsightsDashboardState.builder().build());
private final Repository repository;
private InsightsDashboardViewModel(@NonNull Repository repository) {
this.repository = repository;
repository.getInsightsData(data -> internalState.setValue(getNewState(b -> b.withData(data))));
repository.getUserAvatar(avatar -> internalState.setValue(getNewState(b -> b.withUserAvatar(avatar))));
updateInsecureRecipients();
}
private void updateInsecureRecipients() {
repository.getInsecureRecipients(recipients -> internalState.setValue(getNewState(b -> b.withInsecureRecipients(recipients))));
}
@MainThread
private InsightsDashboardState getNewState(Consumer<InsightsDashboardState.Builder> builderConsumer) {
InsightsDashboardState.Builder builder = internalState.getValue().buildUpon();
builderConsumer.accept(builder);
return builder.build();
}
@NonNull LiveData<InsightsDashboardState> getState() {
return internalState;
}
public void sendSmsInvite(@NonNull Recipient recipient) {
repository.sendSmsInvite(recipient, this::updateInsecureRecipients);
}
interface Repository {
void getInsightsData(@NonNull Consumer<InsightsData> insightsDataConsumer);
void getInsecureRecipients(@NonNull Consumer<List<Recipient>> insecureRecipientsConsumer);
void getUserAvatar(@NonNull Consumer<InsightsUserAvatar> userAvatarConsumer);
void sendSmsInvite(@NonNull Recipient recipient, Runnable onSmsMessageSent);
}
final static class Factory implements ViewModelProvider.Factory {
private final Repository repository;
Factory(@NonNull Repository repository) {
this.repository = repository;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new InsightsDashboardViewModel(repository);
}
}
}

View File

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.insights;
final class InsightsData {
private final boolean hasEnoughData;
private final int percentInsecure;
InsightsData(boolean hasEnoughData, int percentInsecure) {
this.hasEnoughData = hasEnoughData;
this.percentInsecure = percentInsecure;
}
public boolean hasEnoughData() {
return hasEnoughData;
}
public int getPercentInsecure() {
return percentInsecure;
}
}

View File

@ -0,0 +1,118 @@
package org.thoughtcrime.securesms.insights;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Collections;
import java.util.List;
final class InsightsInsecureRecipientsAdapter extends RecyclerView.Adapter<InsightsInsecureRecipientsAdapter.ViewHolder> {
private List<Recipient> data = Collections.emptyList();
private final Consumer<Recipient> onInviteClickedConsumer;
InsightsInsecureRecipientsAdapter(Consumer<Recipient> onInviteClickedConsumer) {
this.onInviteClickedConsumer = onInviteClickedConsumer;
}
public void updateData(List<Recipient> recipients) {
List<Recipient> oldData = data;
data = recipients;
DiffUtil.calculateDiff(new DiffCallback(oldData, data)).dispatchUpdatesTo(this);
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.insights_dashboard_adapter_item, parent, false), this::handleInviteClicked);
}
private void handleInviteClicked(@NonNull Integer position) {
onInviteClickedConsumer.accept(data.get(position));
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(data.get(position));
}
@Override
public int getItemCount() {
return data.size();
}
static final class ViewHolder extends RecyclerView.ViewHolder {
private AvatarImageView avatarImageView;
private TextView displayName;
private ViewHolder(@NonNull View itemView, Consumer<Integer> onInviteClicked) {
super(itemView);
avatarImageView = itemView.findViewById(R.id.recipient_avatar);
displayName = itemView.findViewById(R.id.recipient_display_name);
Button invite = itemView.findViewById(R.id.recipient_invite);
invite.setOnClickListener(v -> {
int adapterPosition = getAdapterPosition();
if (adapterPosition == RecyclerView.NO_POSITION) return;
onInviteClicked.accept(adapterPosition);
});
}
private void bind(@NonNull Recipient recipient) {
displayName.setText(recipient.getDisplayName(itemView.getContext()));
avatarImageView.setAvatar(GlideApp.with(itemView), recipient, false);
}
}
private static class DiffCallback extends DiffUtil.Callback {
private final List<Recipient> oldData;
private final List<Recipient> newData;
private DiffCallback(@NonNull List<Recipient> oldData,
@NonNull List<Recipient> newData)
{
this.oldData = oldData;
this.newData = newData;
}
@Override
public int getOldListSize() {
return oldData.size();
}
@Override
public int getNewListSize() {
return newData.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
return oldData.get(oldItemPosition).getId() == newData.get(newItemPosition).getId();
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
return oldData.get(oldItemPosition).equals(newData.get(newItemPosition));
}
}
}

View File

@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.insights;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
public final class InsightsLauncher {
private static final String MODAL_TAG = "modal.fragment";
public static void showInsightsModal(@NonNull Context context, @NonNull FragmentManager fragmentManager) {
if (InsightsOptOut.userHasOptedOut(context)) return;
final Fragment fragment = fragmentManager.findFragmentByTag(MODAL_TAG);
if (fragment == null) new InsightsModalDialogFragment().show(fragmentManager, MODAL_TAG);
}
public static void showInsightsDashboard(@NonNull FragmentManager fragmentManager) {
new InsightsDashboardDialogFragment().show(fragmentManager, null);
}
}

View File

@ -0,0 +1,131 @@
package org.thoughtcrime.securesms.insights;
import android.animation.AnimatorSet;
import android.app.Dialog;
import android.content.DialogInterface;
import android.content.res.Configuration;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProviders;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ArcProgressBar;
import org.thoughtcrime.securesms.components.AvatarImageView;
public final class InsightsModalDialogFragment extends DialogFragment {
private ArcProgressBar progress;
private TextView securePercentage;
private AvatarImageView avatarImageView;
private AnimatorSet animatorSet;
private View progressContainer;
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
requireFragmentManager().beginTransaction()
.detach(this)
.attach(this)
.commit();
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setStyle(STYLE_NO_FRAME, R.style.Theme_Signal_Insights_Modal);
}
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
Dialog dialog = super.onCreateDialog(savedInstanceState);
dialog.getWindow().setBackgroundDrawableResource(android.R.color.transparent);
return dialog;
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.insights_modal, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
View close = view.findViewById(R.id.insights_modal_close);
Button viewInsights = view.findViewById(R.id.insights_modal_view_insights);
progress = view.findViewById(R.id.insights_modal_progress);
securePercentage = view.findViewById(R.id.insights_modal_percent_secure);
avatarImageView = view.findViewById(R.id.insights_modal_avatar);
progressContainer = view.findViewById(R.id.insights_modal_percent_container);
close.setOnClickListener(v -> dismiss());
viewInsights.setOnClickListener(v -> openInsightsAndDismiss());
initializeViewModel();
}
private void initializeViewModel() {
final InsightsModalViewModel.Repository repository = new InsightsRepository(requireContext());
final InsightsModalViewModel.Factory factory = new InsightsModalViewModel.Factory(repository);
final InsightsModalViewModel viewModel = ViewModelProviders.of(this, factory).get(InsightsModalViewModel.class);
viewModel.getState().observe(this, state -> {
updateInsecurePercent(state.getData());
updateUserAvatar(state.getUserAvatar());
});
}
private void updateInsecurePercent(@Nullable InsightsData insightsData) {
if (insightsData == null) return;
if (animatorSet == null) {
animatorSet = InsightsAnimatorSetFactory.create(insightsData.getPercentInsecure(), this::setProgressPercentage, null, this::setPercentSecureScale, null);
animatorSet.start();
}
}
private void setProgressPercentage(float percent) {
securePercentage.setText(String.valueOf(Math.round(percent * 100)));
progress.setProgress(percent);
}
private void setPercentSecureScale(float scale) {
progressContainer.setScaleX(scale);
progressContainer.setScaleY(scale);
}
private void updateUserAvatar(@Nullable InsightsUserAvatar userAvatar) {
if (userAvatar == null) avatarImageView.setImageDrawable(null);
else userAvatar.load(avatarImageView);
}
@Override
public void onDismiss(@NonNull DialogInterface dialog) {
InsightsOptOut.userRequestedOptOut(requireContext());
}
private void openInsightsAndDismiss() {
InsightsLauncher.showInsightsDashboard(requireFragmentManager());
dismiss();
}
@Override
public void onDestroyView() {
if (animatorSet != null) {
animatorSet.cancel();
animatorSet = null;
}
super.onDestroyView();
}
}

View File

@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.insights;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
final class InsightsModalState {
private final InsightsData insightsData;
private final InsightsUserAvatar userAvatar;
private InsightsModalState(@NonNull Builder builder) {
this.insightsData = builder.insightsData;
this.userAvatar = builder.userAvatar;
}
static @NonNull InsightsModalState.Builder builder() {
return new InsightsModalState.Builder();
}
@NonNull InsightsModalState.Builder buildUpon() {
return builder().withUserAvatar(userAvatar).withData(insightsData);
}
@Nullable InsightsUserAvatar getUserAvatar() {
return userAvatar;
}
@Nullable InsightsData getData() {
return insightsData;
}
static final class Builder {
private InsightsData insightsData;
private InsightsUserAvatar userAvatar;
private Builder() {
}
@NonNull Builder withData(@NonNull InsightsData insightsData) {
this.insightsData = insightsData;
return this;
}
@NonNull Builder withUserAvatar(@NonNull InsightsUserAvatar userAvatar) {
this.userAvatar = userAvatar;
return this;
}
@NonNull InsightsModalState build() {
return new InsightsModalState(this);
}
}
}

View File

@ -0,0 +1,50 @@
package org.thoughtcrime.securesms.insights;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
final class InsightsModalViewModel extends ViewModel {
private final MutableLiveData<InsightsModalState> internalState = new MutableLiveData<>(InsightsModalState.builder().build());
private InsightsModalViewModel(@NonNull Repository repository) {
repository.getInsightsData(data -> internalState.setValue(getNewState(b -> b.withData(data))));
repository.getUserAvatar(avatar -> internalState.setValue(getNewState(b -> b.withUserAvatar(avatar))));
}
@MainThread
private InsightsModalState getNewState(Consumer<InsightsModalState.Builder> builderConsumer) {
InsightsModalState.Builder builder = internalState.getValue().buildUpon();
builderConsumer.accept(builder);
return builder.build();
}
@NonNull LiveData<InsightsModalState> getState() {
return internalState;
}
interface Repository {
void getInsightsData(Consumer<InsightsData> insecurePercentConsumer);
void getUserAvatar(@NonNull Consumer<InsightsUserAvatar> userAvatarConsumer);
}
final static class Factory implements ViewModelProvider.Factory {
private final Repository repository;
Factory(@NonNull Repository repository) {
this.repository = repository;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new InsightsModalViewModel(repository);
}
}
}

View File

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.insights;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
class InsightsOptOut {
private static final String INSIGHTS_OPT_OUT_PREFERENCE = "insights.opt.out";
static boolean userHasOptedOut(@NonNull Context context) {
return TextSecurePreferences.getBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, false);
}
static void userRequestedOptOut(@NonNull Context context) {
TextSecurePreferences.setBooleanPreference(context, INSIGHTS_OPT_OUT_PREFERENCE, true);
}
}

View File

@ -0,0 +1,102 @@
package org.thoughtcrime.securesms.insights;
import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
public class InsightsRepository implements InsightsDashboardViewModel.Repository, InsightsModalViewModel.Repository {
private final Context context;
public InsightsRepository(Context context) {
this.context = context.getApplicationContext();
}
@Override
public void getInsightsData(@NonNull Consumer<InsightsData> insightsDataConsumer) {
SimpleTask.run(() -> {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
List<RecipientId> unregisteredRecipients = recipientDatabase.getNotRegisteredForInsights();
List<RecipientId> registeredRecipients = recipientDatabase.getRegisteredForInsights();
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
int insecure = mmsSmsDatabase.getInsecureMessageCountForRecipients(unregisteredRecipients);
int secure = mmsSmsDatabase.getSecureMessageCountForRecipients(registeredRecipients);
if (insecure + secure == 0) {
return new InsightsData(false, 0);
} else {
return new InsightsData(true, Util.clamp((int) Math.ceil((insecure * 100f) / (insecure + secure)), 0, 100));
}
}, insightsDataConsumer::accept);
}
@Override
public void getInsecureRecipients(@NonNull Consumer<List<Recipient>> insecureRecipientsConsumer) {
SimpleTask.run(() -> {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
List<RecipientId> unregisteredRecipients = recipientDatabase.getUninvitedRecipientsForInsights();
return Stream.of(unregisteredRecipients)
.map(Recipient::resolved)
.toList();
},
insecureRecipientsConsumer::accept);
}
@Override
public void getUserAvatar(@NonNull Consumer<InsightsUserAvatar> avatarConsumer) {
SimpleTask.run(() -> {
Recipient self = Recipient.self().resolve();
String name = Optional.fromNullable(self.getName(context)).or(Optional.fromNullable(TextSecurePreferences.getProfileName(context))).or("");
MaterialColor fallbackColor = self.getColor();
if (fallbackColor == ContactColors.UNKNOWN_COLOR && !TextUtils.isEmpty(name)) {
fallbackColor = ContactColors.generateFor(name);
}
return new InsightsUserAvatar(new ProfileContactPhoto(self.getId(), String.valueOf(TextSecurePreferences.getProfileAvatarId(context))),
fallbackColor,
new GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40));
}, avatarConsumer::accept);
}
@Override
public void sendSmsInvite(@NonNull Recipient recipient, Runnable onSmsMessageSent) {
SimpleTask.run(() -> {
Recipient resolved = recipient.resolve();
int subscriptionId = resolved.getDefaultSubscriptionId().or(-1);
String message = context.getString(R.string.InviteActivity_lets_switch_to_signal, context.getString(R.string.install_url));
MessageSender.send(context, new OutgoingTextMessage(resolved, message, subscriptionId), -1L, true, null);
RecipientDatabase database = DatabaseFactory.getRecipientDatabase(context);
database.setHasSentInvite(recipient.getId());
return null;
}, v -> onSmsMessageSent.run());
}
}

View File

@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.insights;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
class InsightsUserAvatar {
private final ProfileContactPhoto profileContactPhoto;
private final MaterialColor fallbackColor;
private final FallbackContactPhoto fallbackContactPhoto;
InsightsUserAvatar(@NonNull ProfileContactPhoto profileContactPhoto, @NonNull MaterialColor fallbackColor, @NonNull FallbackContactPhoto fallbackContactPhoto) {
this.profileContactPhoto = profileContactPhoto;
this.fallbackColor = fallbackColor;
this.fallbackContactPhoto = fallbackContactPhoto;
}
private Drawable fallbackDrawable(@NonNull Context context) {
return fallbackContactPhoto.asDrawable(context, fallbackColor.toAvatarColor(context));
}
void load(ImageView into) {
GlideApp.with(into)
.load(profileContactPhoto)
.error(fallbackDrawable(into.getContext()))
.circleCrop()
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(into);
}
}

View File

@ -0,0 +1,144 @@
package org.thoughtcrime.securesms.invites;
import android.content.Context;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.components.reminder.SecondInviteReminder;
import org.thoughtcrime.securesms.components.reminder.FirstInviteReminder;
import org.thoughtcrime.securesms.components.reminder.Reminder;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.concurrent.atomic.AtomicReference;
public final class InviteReminderModel {
private static final int FIRST_INVITE_REMINDER_MESSAGE_THRESHOLD = 10;
private static final int SECOND_INVITE_REMINDER_MESSAGE_THRESHOLD = 500;
private final Context context;
private final Repository repository;
private final AtomicReference<ReminderInfo> reminderInfo = new AtomicReference<>();
public InviteReminderModel(@NonNull Context context, @NonNull Repository repository) {
this.context = context;
this.repository = repository;
}
@MainThread
public void loadReminder(LiveRecipient liveRecipient, Runnable reminderCheckComplete) {
SimpleTask.run(() -> createReminderInfo(liveRecipient.resolve()), result -> {
reminderInfo.set(result);
reminderCheckComplete.run();
});
}
@WorkerThread
private @NonNull ReminderInfo createReminderInfo(Recipient recipient) {
Recipient resolved = recipient.resolve();
if (resolved.isRegistered() || resolved.isGroup() || resolved.hasSeenSecondInviteReminder()) {
return new NoReminderInfo();
}
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
long threadId = threadDatabase.getThreadIdFor(recipient);
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
int conversationCount = mmsSmsDatabase.getInsecureSentCount(threadId);
if (conversationCount >= SECOND_INVITE_REMINDER_MESSAGE_THRESHOLD && !resolved.hasSeenSecondInviteReminder()) {
return new SecondInviteReminderInfo(context, resolved, repository, repository.getPercentOfInsecureMessages(conversationCount));
} else if (conversationCount >= FIRST_INVITE_REMINDER_MESSAGE_THRESHOLD && !resolved.hasSeenFirstInviteReminder()) {
return new FirstInviteReminderInfo(context, resolved, repository, repository.getPercentOfInsecureMessages(conversationCount));
} else {
return new NoReminderInfo();
}
}
public @NonNull Optional<Reminder> getReminder() {
ReminderInfo info = reminderInfo.get();
if (info == null) return Optional.absent();
else return Optional.fromNullable(info.reminder);
}
public void dismissReminder() {
final ReminderInfo info = reminderInfo.getAndSet(null);
SimpleTask.run(() -> {
info.dismiss();
return null;
}, (v) -> {});
}
interface Repository {
void setHasSeenFirstInviteReminder(Recipient recipient);
void setHasSeenSecondInviteReminder(Recipient recipient);
int getPercentOfInsecureMessages(int insecureCount);
}
private static abstract class ReminderInfo {
private final Reminder reminder;
ReminderInfo(Reminder reminder) {
this.reminder = reminder;
}
@WorkerThread
void dismiss() {
}
}
private static class NoReminderInfo extends ReminderInfo {
private NoReminderInfo() {
super(null);
}
}
private class FirstInviteReminderInfo extends ReminderInfo {
private final Repository repository;
private final Recipient recipient;
private FirstInviteReminderInfo(@NonNull Context context, @NonNull Recipient recipient, @NonNull Repository repository, int percentInsecure) {
super(new FirstInviteReminder(context, recipient, percentInsecure));
this.recipient = recipient;
this.repository = repository;
}
@Override
@WorkerThread
void dismiss() {
repository.setHasSeenFirstInviteReminder(recipient);
}
}
private static class SecondInviteReminderInfo extends ReminderInfo {
private final Repository repository;
private final Recipient recipient;
private SecondInviteReminderInfo(@NonNull Context context, @NonNull Recipient recipient, @NonNull Repository repository, int percentInsecure) {
super(new SecondInviteReminder(context, recipient, percentInsecure));
this.repository = repository;
this.recipient = recipient;
}
@Override
@WorkerThread
void dismiss() {
repository.setHasSeenSecondInviteReminder(recipient);
}
}
}

View File

@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.invites;
import android.content.Context;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.List;
public final class InviteReminderRepository implements InviteReminderModel.Repository {
private final Context context;
public InviteReminderRepository(Context context) {
this.context = context;
}
@Override
public void setHasSeenFirstInviteReminder(Recipient recipient) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
recipientDatabase.setSeenFirstInviteReminder(recipient.getId());
}
@Override
public void setHasSeenSecondInviteReminder(Recipient recipient) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
recipientDatabase.setSeenSecondInviteReminder(recipient.getId());
}
@Override
public int getPercentOfInsecureMessages(int insecureCount) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
List<RecipientId> registeredRecipients = recipientDatabase.getRegisteredForInsights();
List<RecipientId> unregisteredRecipients = recipientDatabase.getNotRegisteredForInsights();
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
int insecure = mmsSmsDatabase.getInsecureMessageCountForRecipients(unregisteredRecipients);
int secure = mmsSmsDatabase.getSecureMessageCountForRecipients(registeredRecipients);
if (insecure + secure == 0) return 0;
return Math.round(100f * (insecureCount / (float) (insecure + secure)));
}
}

View File

@ -47,6 +47,8 @@ import java.util.List;
import java.util.Objects;
import java.util.UUID;
import static org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier;
public class Recipient {
public static final Recipient UNKNOWN = new Recipient(RecipientId.UNKNOWN, new RecipientDetails());
@ -69,7 +71,6 @@ public class Recipient {
private final Uri messageRingtone;
private final Uri callRingtone;
private final MaterialColor color;
private final boolean seenInviteReminder;
private final Optional<Integer> defaultSubscriptionId;
private final int expireMessages;
private final RegisteredState registered;
@ -85,6 +86,7 @@ public class Recipient {
private final UnidentifiedAccessMode unidentifiedAccessMode;
private final boolean forceSmsSelection;
private final boolean uuidSupported;
private final InsightsBannerTier insightsBannerTier;
/**
@ -246,7 +248,7 @@ public class Recipient {
this.messageRingtone = null;
this.callRingtone = null;
this.color = null;
this.seenInviteReminder = true;
this.insightsBannerTier = InsightsBannerTier.TIER_TWO;
this.defaultSubscriptionId = Optional.absent();
this.expireMessages = 0;
this.registered = RegisteredState.UNKNOWN;
@ -281,7 +283,7 @@ public class Recipient {
this.messageRingtone = details.messageRingtone;
this.callRingtone = details.callRingtone;
this.color = details.color;
this.seenInviteReminder = details.seenInviteReminder;
this.insightsBannerTier = details.insightsBannerTier;
this.defaultSubscriptionId = details.defaultSubscriptionId;
this.expireMessages = details.expireMessages;
this.registered = details.registered;
@ -571,8 +573,12 @@ public class Recipient {
return expireMessages;
}
public boolean hasSeenInviteReminder() {
return seenInviteReminder;
public boolean hasSeenFirstInviteReminder() {
return insightsBannerTier.seen(InsightsBannerTier.TIER_ONE);
}
public boolean hasSeenSecondInviteReminder() {
return insightsBannerTier.seen(InsightsBannerTier.TIER_TWO);
}
public @NonNull RegisteredState getRegistered() {

View File

@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
@ -40,7 +41,6 @@ public class RecipientDetails {
final int expireMessages;
final List<Recipient> participants;
final String profileName;
final boolean seenInviteReminder;
final Optional<Integer> defaultSubscriptionId;
final RegisteredState registered;
final byte[] profileKey;
@ -52,6 +52,7 @@ public class RecipientDetails {
final UnidentifiedAccessMode unidentifiedAccessMode;
final boolean forceSmsSelection;
final boolean uuidSuported;
final InsightsBannerTier insightsBannerTier;
RecipientDetails(@NonNull Context context,
@Nullable String name,
@ -79,7 +80,6 @@ public class RecipientDetails {
this.expireMessages = settings.getExpireMessages();
this.participants = participants == null ? new LinkedList<>() : participants;
this.profileName = isLocalNumber ? TextSecurePreferences.getProfileName(context) : settings.getProfileName();
this.seenInviteReminder = settings.hasSeenInviteReminder();
this.defaultSubscriptionId = settings.getDefaultSubscriptionId();
this.registered = settings.getRegistered();
this.profileKey = settings.getProfileKey();
@ -91,6 +91,7 @@ public class RecipientDetails {
this.unidentifiedAccessMode = settings.getUnidentifiedAccessMode();
this.forceSmsSelection = settings.isForceSmsSelection();
this.uuidSuported = settings.isUuidSupported();
this.insightsBannerTier = settings.getInsightsBannerTier();
if (name == null) this.name = settings.getSystemDisplayName();
else this.name = name;
@ -115,7 +116,7 @@ public class RecipientDetails {
this.expireMessages = 0;
this.participants = new LinkedList<>();
this.profileName = null;
this.seenInviteReminder = true;
this.insightsBannerTier = InsightsBannerTier.TIER_TWO;
this.defaultSubscriptionId = Optional.absent();
this.registered = RegisteredState.UNKNOWN;
this.profileKey = null;