Repository: alexstyl/Memento-Calendar Branch: main Commit: d224f0af53ee Files: 903 Total size: 1.7 MB Directory structure: gitextract_87q2jt04/ ├── .circleci/ │ ├── ci-scripts/ │ │ ├── accept-android-licenses.sh │ │ ├── ensure-sdkmanager.sh │ │ └── mock-google-services.json │ └── config.yml ├── .github/ │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE.md │ └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── LICENSE ├── PEOPLE.md ├── README.md ├── android_common/ │ ├── build.gradle │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── alexstyl/ │ │ ├── android/ │ │ │ ├── AndroidLogger.kt │ │ │ ├── Version.kt │ │ │ ├── ViewVisibility.java │ │ │ ├── preferences/ │ │ │ │ └── widget/ │ │ │ │ └── TimePreference.java │ │ │ └── widget/ │ │ │ └── AppWidgetId.java │ │ ├── resources/ │ │ │ ├── AndroidDimensionResources.java │ │ │ └── DimensionResources.java │ │ └── specialdates/ │ │ ├── AndroidStrings.kt │ │ ├── TextViewLabelSetter.java │ │ ├── date/ │ │ │ └── IntentDateExtensions.kt │ │ ├── events/ │ │ │ └── database/ │ │ │ └── EventColumns.java │ │ └── wear/ │ │ └── SharedConstants.java │ └── res/ │ ├── values/ │ │ ├── colors.xml │ │ ├── strings-non-translatable.xml │ │ └── strings.xml │ ├── values-cs/ │ │ └── strings.xml │ ├── values-de/ │ │ └── strings.xml │ ├── values-el/ │ │ └── strings.xml │ ├── values-fr/ │ │ └── strings.xml │ ├── values-it/ │ │ └── strings.xml │ ├── values-lv/ │ │ └── bools.xml │ ├── values-lv-rLV/ │ │ └── strings.xml │ ├── values-nl/ │ │ └── strings.xml │ └── values-sk/ │ └── bool.xml ├── android_mobile/ │ ├── .gitignore │ ├── build.gradle │ ├── google_services.gradle │ ├── proguard-rules.pro │ └── src/ │ ├── debug/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── mock/ │ │ │ └── facebook-calendar.ics │ │ ├── java/ │ │ │ └── com/ │ │ │ └── alexstyl/ │ │ │ └── specialdates/ │ │ │ ├── DebugAppComponent.java │ │ │ ├── DebugApplication.java │ │ │ ├── OptionalDependencies.java │ │ │ ├── debug/ │ │ │ │ ├── DebugActivity.java │ │ │ │ ├── DebugFragment.kt │ │ │ │ ├── DebugModule.java │ │ │ │ └── DebugPreferences.java │ │ │ ├── donate/ │ │ │ │ └── DebugDonationPreferences.java │ │ │ └── events/ │ │ │ └── peopleevents/ │ │ │ └── DebugPeopleEventsUpdater.java │ │ └── res/ │ │ └── layout/ │ │ ├── activity_debug.xml │ │ ├── debug_activity_animations.xml │ │ └── debug_activity_mixing_colors.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── aidl/ │ │ │ └── com/ │ │ │ └── android/ │ │ │ └── vending/ │ │ │ └── billing/ │ │ │ └── IInAppBillingService.aidl │ │ ├── java/ │ │ │ ├── android/ │ │ │ │ └── support/ │ │ │ │ └── v4/ │ │ │ │ └── preference/ │ │ │ │ ├── PreferenceFragment.java │ │ │ │ └── PreferenceManagerCompat.java │ │ │ └── com/ │ │ │ └── alexstyl/ │ │ │ ├── android/ │ │ │ │ ├── Bitmap.kt │ │ │ │ ├── SimpleAnimatorListener.java │ │ │ │ ├── Uri.kt │ │ │ │ └── preferences/ │ │ │ │ └── PreferenceKeyId.java │ │ │ ├── resources/ │ │ │ │ ├── AndroidColors.kt │ │ │ │ └── ResourcesModule.java │ │ │ └── specialdates/ │ │ │ ├── AndroidApplicationModule.java │ │ │ ├── AppComponent.java │ │ │ ├── DeviceConfigurationUpdatedReceiver.java │ │ │ ├── EasyPreferences.java │ │ │ ├── ExternalNavigator.java │ │ │ ├── FabricTracker.java │ │ │ ├── JobsCreator.kt │ │ │ ├── MementoApplication.java │ │ │ ├── MementoConstants.kt │ │ │ ├── SQLArgumentBuilder.java │ │ │ ├── ShareAppIntentCreator.java │ │ │ ├── addevent/ │ │ │ │ ├── AccountData.java │ │ │ │ ├── AddEventActivity.kt │ │ │ │ ├── AddEventModule.kt │ │ │ │ ├── AndroidAddEventView.kt │ │ │ │ ├── AndroidContactOperationsExecutor.kt │ │ │ │ ├── AndroidEventIcons.kt │ │ │ │ ├── ContactDetailsListener.java │ │ │ │ ├── ContactEventViewHolder.java │ │ │ │ ├── ContactEventsAdapter.java │ │ │ │ ├── ContactSuggestionViewHolder.java │ │ │ │ ├── DiscardPromptDialog.java │ │ │ │ ├── EventDatePickerDialogFragment.java │ │ │ │ ├── ImageIntentFactory.java │ │ │ │ ├── OnCameraClickedListener.java │ │ │ │ ├── OperationsFactory.kt │ │ │ │ ├── ToastDisplayer.kt │ │ │ │ ├── ToolbarBackgroundAnimator.java │ │ │ │ ├── ToolbarBackgroundFadingAnimator.java │ │ │ │ ├── ToolbarBackgroundStubAnimator.java │ │ │ │ ├── UriFilePathProvider.kt │ │ │ │ ├── WriteableAccountsProvider.java │ │ │ │ ├── bottomsheet/ │ │ │ │ │ ├── BottomSheetPicturesDialog.kt │ │ │ │ │ ├── ClearImageViewHolder.java │ │ │ │ │ ├── ImagePickerOptionViewHolder.kt │ │ │ │ │ ├── ImagePickerOptionsAdapter.java │ │ │ │ │ ├── IntentResolver.kt │ │ │ │ │ ├── PhotoPickerViewModel.kt │ │ │ │ │ └── PhotoPickerViewModelFactory.kt │ │ │ │ └── ui/ │ │ │ │ ├── AvatarPickerView.java │ │ │ │ ├── ContactSuggestionView.java │ │ │ │ ├── ContactsAdapter.java │ │ │ │ ├── DeviceContactsFilter.kt │ │ │ │ └── EventDatePicker.java │ │ │ ├── analytics/ │ │ │ │ ├── Action.java │ │ │ │ ├── ActionWithParameters.java │ │ │ │ ├── AnalyticsModule.kt │ │ │ │ ├── CompositeAnalytics.kt │ │ │ │ ├── FirebaseAnalyticsImpl.kt │ │ │ │ └── MixPanel.kt │ │ │ ├── contact/ │ │ │ │ ├── AndroidContactFactory.kt │ │ │ │ ├── AndroidContactsProviderSource.kt │ │ │ │ ├── AndroidContactsQuery.java │ │ │ │ ├── ContactIntentExtractor.kt │ │ │ │ ├── ContactsModule.kt │ │ │ │ ├── EmptyContactSource.kt │ │ │ │ └── FacebookContactsSource.kt │ │ │ ├── dailyreminder/ │ │ │ │ ├── AlarmManagerCompat.java │ │ │ │ ├── AndroidDailyReminderNotifier.kt │ │ │ │ ├── AndroidDailyReminderScheduler.kt │ │ │ │ ├── AndroidDailyReminderViewModelFactory.kt │ │ │ │ ├── DailyReminderDebugPreferences.java │ │ │ │ ├── DailyReminderJob.kt │ │ │ │ ├── DailyReminderModule.java │ │ │ │ ├── DailyReminderOreoChannelCreator.kt │ │ │ │ ├── DailyReminderPreferences.kt │ │ │ │ ├── NoActions.kt │ │ │ │ ├── NotificationConstants.kt │ │ │ │ ├── NotificationDailyReminderView.kt │ │ │ │ └── actions/ │ │ │ │ ├── AndroidContactActionsView.kt │ │ │ │ ├── ContactActionsModule.java │ │ │ │ ├── ContactActionsPresenter.kt │ │ │ │ ├── ContactActionsView.kt │ │ │ │ └── PersonActionsActivity.kt │ │ │ ├── date/ │ │ │ │ ├── AndroidDateLabelCreator.kt │ │ │ │ └── DateModule.java │ │ │ ├── donate/ │ │ │ │ ├── AndroidDonation.java │ │ │ │ ├── AndroidDonationConstants.java │ │ │ │ ├── AndroidDonationService.java │ │ │ │ ├── DonateActivity.java │ │ │ │ ├── DonateModule.java │ │ │ │ ├── DonatePresenter.java │ │ │ │ ├── DonationPreferences.java │ │ │ │ ├── SimpleOnSeekBarChangeListener.java │ │ │ │ └── util/ │ │ │ │ ├── IabBroadcastReceiver.java │ │ │ │ ├── IabException.java │ │ │ │ ├── IabHelper.java │ │ │ │ ├── IabResult.java │ │ │ │ ├── Inventory.java │ │ │ │ ├── Purchase.java │ │ │ │ ├── Security.java │ │ │ │ └── SkuDetails.java │ │ │ ├── events/ │ │ │ │ ├── ContactsObserver.java │ │ │ │ ├── PreferenceChangedEventsUpdateTrigger.java │ │ │ │ ├── bankholidays/ │ │ │ │ │ ├── BankHolidaysModule.java │ │ │ │ │ └── BankHolidaysPreferences.java │ │ │ │ ├── database/ │ │ │ │ │ ├── ContactColumns.java │ │ │ │ │ ├── DatabaseContract.java │ │ │ │ │ └── EventSQLiteOpenHelper.kt │ │ │ │ ├── namedays/ │ │ │ │ │ ├── NamedayModule.java │ │ │ │ │ ├── NamedayPreferences.java │ │ │ │ │ ├── activity/ │ │ │ │ │ │ ├── AndroidNamedaysOnADayView.kt │ │ │ │ │ │ ├── CelebratingContactViewHolder.kt │ │ │ │ │ │ ├── NameViewHolder.kt │ │ │ │ │ │ ├── NamedayScreenViewHolder.kt │ │ │ │ │ │ ├── NamedaysInADayModule.java │ │ │ │ │ │ ├── NamedaysOnADayActivity.kt │ │ │ │ │ │ ├── NamedaysOnADayNavigator.kt │ │ │ │ │ │ ├── NamedaysScreenAdapter.java │ │ │ │ │ │ ├── NamedaysScreenViewHolderFactory.kt │ │ │ │ │ │ └── NamedaysViewModelDiff.java │ │ │ │ │ └── calendar/ │ │ │ │ │ └── resource/ │ │ │ │ │ └── AndroidJSONResourceLoader.kt │ │ │ │ └── peopleevents/ │ │ │ │ ├── AndroidPeopleEventsPersister.kt │ │ │ │ ├── AndroidPeopleEventsProvider.kt │ │ │ │ ├── AndroidPeopleEventsRepository.kt │ │ │ │ ├── AndroidUpcomingEventSettings.kt │ │ │ │ ├── ContactEventsMarshaller.java │ │ │ │ ├── CustomEventProvider.kt │ │ │ │ └── PeopleEventsModule.kt │ │ │ ├── facebook/ │ │ │ │ ├── AndroidFacebookPreferences.java │ │ │ │ ├── FacebookLogoutService.java │ │ │ │ ├── FacebookModule.java │ │ │ │ ├── FacebookProfileActivity.java │ │ │ │ ├── FacebookProfilePresenter.java │ │ │ │ ├── FacebookProfileView.java │ │ │ │ ├── OnFacebookLogOutCallback.java │ │ │ │ ├── ScreenOrientationLock.java │ │ │ │ ├── friendimport/ │ │ │ │ │ ├── CalendarURLCreator.java │ │ │ │ │ ├── FacebookFriendsIntentService.java │ │ │ │ │ ├── FacebookFriendsPersister.java │ │ │ │ │ └── FacebookFriendsScheduler.java │ │ │ │ └── login/ │ │ │ │ ├── CookieResetter.java │ │ │ │ ├── CredentialsExtractor.java │ │ │ │ ├── FBImportClient.java │ │ │ │ ├── FacebookImportView.java │ │ │ │ ├── FacebookLogInActivity.java │ │ │ │ ├── FacebookLogInCallback.java │ │ │ │ ├── FacebookLogInException.java │ │ │ │ ├── FacebookWebView.java │ │ │ │ └── UserCredentialsExtractorTask.java │ │ │ ├── home/ │ │ │ │ ├── DonationBannerView.kt │ │ │ │ ├── HomeActivity.java │ │ │ │ ├── HomeNavigator.kt │ │ │ │ ├── HomeViewPagerAdapter.java │ │ │ │ ├── OnCloseBannerListener.kt │ │ │ │ └── SearchTransitioner.java │ │ │ ├── images/ │ │ │ │ ├── AndroidContactsImageDownloader.kt │ │ │ │ ├── CrossFadeBitmapDisplayer.java │ │ │ │ ├── CrossFadeCircleBitmapDisplayer.java │ │ │ │ ├── DecodedImage.java │ │ │ │ ├── ImageDecoder.kt │ │ │ │ ├── ImageLoadedConsumer.java │ │ │ │ ├── ImageLoader.kt │ │ │ │ ├── ImageModule.java │ │ │ │ ├── NutraBaseImageDecoder.java │ │ │ │ ├── SimpleImageLoadedConsumer.java │ │ │ │ └── UILImageLoader.java │ │ │ ├── people/ │ │ │ │ ├── ImportFromFacebookViewHolder.kt │ │ │ │ ├── NoContactViewHolder.kt │ │ │ │ ├── PeopleAdapter.java │ │ │ │ ├── PeopleDiffCallback.kt │ │ │ │ ├── PeopleFragment.java │ │ │ │ ├── PeopleItemDecorator.java │ │ │ │ ├── PeopleModule.java │ │ │ │ └── PeopleViewHolder.java │ │ │ ├── permissions/ │ │ │ │ ├── AndroidPermissions.kt │ │ │ │ └── ContactPermissionActivity.java │ │ │ ├── person/ │ │ │ │ ├── AndroidContactActions.kt │ │ │ │ ├── AndroidContactActionsProvider.kt │ │ │ │ ├── AndroidPersonView.kt │ │ │ │ ├── BottomSheetIntentAdapter.java │ │ │ │ ├── BottomSheetIntentDialog.kt │ │ │ │ ├── BottomSheetIntentListener.java │ │ │ │ ├── CallMethod.kt │ │ │ │ ├── CallViewHolder.java │ │ │ │ ├── CompositeContactActionsProvider.kt │ │ │ │ ├── ContactAction.kt │ │ │ │ ├── ContactActionViewModel.kt │ │ │ │ ├── ContactActionsAdapter.java │ │ │ │ ├── ContactActionsPageViewHolder.kt │ │ │ │ ├── ContactActionsProvider.kt │ │ │ │ ├── ContactItemsAdapter.java │ │ │ │ ├── EventAdapter.java │ │ │ │ ├── EventPageViewHolder.kt │ │ │ │ ├── EventPressedListener.java │ │ │ │ ├── EventViewHolder.java │ │ │ │ ├── FacebookContactActionsProvider.kt │ │ │ │ ├── IntentOptionViewHolder.java │ │ │ │ ├── IntentOptionViewModel.java │ │ │ │ ├── PageViewHolder.kt │ │ │ │ ├── PersonActivity.kt │ │ │ │ ├── PersonAvailableActionsViewModel.kt │ │ │ │ ├── PersonDetailsNavigator.kt │ │ │ │ ├── PersonDetailsViewModelFactory.kt │ │ │ │ ├── PersonInfoViewModel.kt │ │ │ │ ├── PersonModule.kt │ │ │ │ ├── PersonPresenter.kt │ │ │ │ └── PersonView.kt │ │ │ ├── receiver/ │ │ │ │ └── BootCompleteReceiver.java │ │ │ ├── search/ │ │ │ │ ├── BackKeyEditText.java │ │ │ │ ├── CaseInsensitiveComparator.java │ │ │ │ ├── ContactEventViewModel.kt │ │ │ │ ├── ContactEventViewModelFactory.kt │ │ │ │ ├── DelayedTextWatcher.java │ │ │ │ ├── MoreViewHolder.java │ │ │ │ ├── NameSuggestionsAdapter.java │ │ │ │ ├── NamedayCard.java │ │ │ │ ├── NamedaysLoader.java │ │ │ │ ├── NamesFilter.java │ │ │ │ ├── NoResultsViewHolder.java │ │ │ │ ├── OnBackKeyPressedListener.java │ │ │ │ ├── SearchActivity.java │ │ │ │ ├── SearchBar.java │ │ │ │ ├── SearchHintCreator.java │ │ │ │ ├── SearchLoader.java │ │ │ │ ├── SearchModule.java │ │ │ │ ├── SearchNavigator.kt │ │ │ │ ├── SearchResultAdapter.java │ │ │ │ ├── SearchResultContactViewHolder.java │ │ │ │ ├── SearchResultNamedayViewHolder.java │ │ │ │ ├── SearchResults.java │ │ │ │ ├── SuggstedNameViewHolder.java │ │ │ │ └── ToggleVisibilityOnFocus.java │ │ │ ├── settings/ │ │ │ │ ├── ClickableRingtonePreference.java │ │ │ │ ├── DailyReminderActivity.java │ │ │ │ ├── DailyReminderFragment.kt │ │ │ │ ├── DailyReminderNavigator.kt │ │ │ │ ├── MementoThemeNameComparator.java │ │ │ │ ├── NamedayListPreference.java │ │ │ │ ├── OnlyGreekSupportedDialog.java │ │ │ │ ├── PreferenceNotFoundException.kt │ │ │ │ ├── ThemeSelectAdapter.java │ │ │ │ ├── ThemeSelectDialog.java │ │ │ │ ├── ThemeViewHolder.java │ │ │ │ └── UserSettingsFragment.java │ │ │ ├── support/ │ │ │ │ ├── AskForSupport.kt │ │ │ │ ├── CallForRatingPreferences.java │ │ │ │ ├── Emoticon.java │ │ │ │ ├── OnSupportCardClickListener.java │ │ │ │ └── RateDialog.java │ │ │ ├── theming/ │ │ │ │ ├── AttributeExtractor.java │ │ │ │ ├── DrawableTinter.java │ │ │ │ ├── MementoTheme.kt │ │ │ │ ├── ThemeMonitor.java │ │ │ │ ├── Themer.kt │ │ │ │ ├── ThemingModule.java │ │ │ │ └── ThemingPreferences.kt │ │ │ ├── transition/ │ │ │ │ ├── FadeInTransition.java │ │ │ │ ├── FadeOutTransition.java │ │ │ │ └── SimpleTransitionListener.java │ │ │ ├── ui/ │ │ │ │ ├── DummyHideStatusBarListener.java │ │ │ │ ├── HorizontalDivider.java │ │ │ │ ├── LolipopHideStatusBarListener.java │ │ │ │ ├── MementoCardView.java │ │ │ │ ├── ViewFader.java │ │ │ │ ├── base/ │ │ │ │ │ ├── MementoActivity.kt │ │ │ │ │ ├── MementoDialog.java │ │ │ │ │ ├── MementoFragment.java │ │ │ │ │ ├── MementoPreferenceActivity.kt │ │ │ │ │ ├── MementoPreferenceFragment.kt │ │ │ │ │ └── ThemedMementoActivity.kt │ │ │ │ ├── dialog/ │ │ │ │ │ └── ProgressFragmentDialog.java │ │ │ │ ├── loader/ │ │ │ │ │ └── SimpleAsyncTaskLoader.java │ │ │ │ └── widget/ │ │ │ │ ├── AndroidLetterPainter.java │ │ │ │ ├── AvatarLayout.java │ │ │ │ ├── ColorImageView.java │ │ │ │ ├── ForegroundLinearLayout.java │ │ │ │ ├── LogoView.java │ │ │ │ ├── MementoToolbar.java │ │ │ │ ├── SpacesItemDecoration.java │ │ │ │ └── ViewModule.java │ │ │ ├── upcoming/ │ │ │ │ ├── AndroidUpcomingDateStringCreator.kt │ │ │ │ ├── AndroidUpcomingMVPView.kt │ │ │ │ ├── BankholidayViewHolder.kt │ │ │ │ ├── ContactEventViewHolder.kt │ │ │ │ ├── DateHeaderViewHolder.kt │ │ │ │ ├── DatePickerDialogFragment.java │ │ │ │ ├── NamedaysViewHolder.kt │ │ │ │ ├── PeopleEventsRefreshJob.kt │ │ │ │ ├── UpcomingEventsAdapter.java │ │ │ │ ├── UpcomingEventsDecorator.java │ │ │ │ ├── UpcomingEventsDiffCallback.kt │ │ │ │ ├── UpcomingEventsFragment.kt │ │ │ │ ├── UpcomingEventsModule.kt │ │ │ │ ├── UpcomingRowViewHolder.kt │ │ │ │ ├── UpcomingViewHolderFactory.kt │ │ │ │ ├── view/ │ │ │ │ │ ├── ExposedSearchToolbar.java │ │ │ │ │ └── OnUpcomingEventClickedListener.java │ │ │ │ └── widget/ │ │ │ │ ├── RecentUpcomingPeopleEventsModule.kt │ │ │ │ ├── list/ │ │ │ │ │ ├── BankHolidayBinder.kt │ │ │ │ │ ├── CircularAvatarFactory.java │ │ │ │ │ ├── ContactEventBinder.kt │ │ │ │ │ ├── DateHeaderBinder.kt │ │ │ │ │ ├── NamedaysBinder.kt │ │ │ │ │ ├── UpcomingEventViewBinder.java │ │ │ │ │ ├── UpcomingEventsRemoteViewService.java │ │ │ │ │ ├── UpcomingEventsScrollingAppWidgetProvider.java │ │ │ │ │ ├── UpcomingEventsScrollingWidgetView.java │ │ │ │ │ ├── UpcomingEventsViewsFactory.java │ │ │ │ │ └── WidgetRouterActivity.kt │ │ │ │ └── today/ │ │ │ │ ├── AndroidRecentPeopleEventsView.kt │ │ │ │ ├── LuminanceAnalyzer.kt │ │ │ │ ├── SimpleOnSeekBarChangeListener.java │ │ │ │ ├── TodayAppWidgetProvider.kt │ │ │ │ ├── TodayUpcomingEventsView.java │ │ │ │ ├── TransparencyColorCalculator.java │ │ │ │ ├── UpcomingWidgetConfigurationPanel.java │ │ │ │ ├── UpcomingWidgetConfigureActivity.kt │ │ │ │ ├── UpcomingWidgetPreferences.kt │ │ │ │ ├── UpcomingWidgetPreviewLayout.java │ │ │ │ ├── UserOptions.kt │ │ │ │ ├── WidgetColorCalculator.java │ │ │ │ ├── WidgetImageLoader.java │ │ │ │ └── WidgetVariant.java │ │ │ ├── util/ │ │ │ │ ├── GreekNameUtils.java │ │ │ │ └── NaturalLanguageUtils.java │ │ │ └── wear/ │ │ │ ├── WearSyncService.java │ │ │ └── WearSyncUpcomingEventsView.java │ │ └── res/ │ │ ├── anim/ │ │ │ ├── bounce.xml │ │ │ ├── grow_from_bottom.xml │ │ │ ├── grow_from_top.xml │ │ │ ├── heartbeat.xml │ │ │ ├── slide_in_below.xml │ │ │ ├── slide_in_from_above.xml │ │ │ ├── slide_in_from_below.xml │ │ │ ├── slide_out_above.xml │ │ │ ├── slide_out_from_above.xml │ │ │ ├── slide_out_from_below.xml │ │ │ ├── slide_up_left.xml │ │ │ ├── slide_up_right.xml │ │ │ └── stay.xml │ │ ├── drawable/ │ │ │ ├── ab_background_textured_dayslight.xml │ │ │ ├── background_daymarker.xml │ │ │ ├── background_suggestions.xml │ │ │ ├── black_to_transparent_gradient_facing_down.xml │ │ │ ├── btn_cab_done_dayslight.xml │ │ │ ├── card_noshadow.xml │ │ │ ├── dayslight_progress_horizontal_holo_light.xml │ │ │ ├── dayslight_progress_indeterminate_horizontal_holo_light.xml │ │ │ ├── divider_top_horizontal.xml │ │ │ ├── ic_add_contact_42px.xml │ │ │ ├── ic_add_person_24px.xml │ │ │ ├── ic_arrow_back_white_24dp.xml │ │ │ ├── ic_bankholidays.xml │ │ │ ├── ic_bankholidays_disabled.xml │ │ │ ├── ic_call.xml │ │ │ ├── ic_camera.xml │ │ │ ├── ic_check_white.xml │ │ │ ├── ic_clear.xml │ │ │ ├── ic_close_black.xml │ │ │ ├── ic_close_white.xml │ │ │ ├── ic_contacts.xml │ │ │ ├── ic_contacts_disabled.xml │ │ │ ├── ic_donate.xml │ │ │ ├── ic_events.xml │ │ │ ├── ic_f_icon.xml │ │ │ ├── ic_facebook_import_friends.xml │ │ │ ├── ic_facebook_like.xml │ │ │ ├── ic_facebook_logo_traced.xml │ │ │ ├── ic_facebook_sad.xml │ │ │ ├── ic_friend_invite.xml │ │ │ ├── ic_gift.xml │ │ │ ├── ic_github.xml │ │ │ ├── ic_licenses.xml │ │ │ ├── ic_menu.xml │ │ │ ├── ic_message.xml │ │ │ ├── ic_namedays.xml │ │ │ ├── ic_namedays_disabled.xml │ │ │ ├── ic_person_120.xml │ │ │ ├── ic_person_24dp.xml │ │ │ ├── ic_person_96dp.xml │ │ │ ├── ic_person_light_24dp.xml │ │ │ ├── ic_search_black_24dp.xml │ │ │ ├── ic_settings.xml │ │ │ ├── progress_horizontal_dayslight.xml │ │ │ ├── selectable_background_dayslight.xml │ │ │ ├── spinner_background_ab_dayslight.xml │ │ │ └── tab_indicator_ab_dayslight.xml │ │ ├── drawable-v21/ │ │ │ ├── blue_ripple.xml │ │ │ ├── neutral_ripple.xml │ │ │ ├── neutral_ripple_no_mask.xml │ │ │ ├── red_ripple.xml │ │ │ └── red_ripple_no_mask.xml │ │ ├── layout/ │ │ │ ├── abc_dropdown_title.xml │ │ │ ├── activity_add_event.xml │ │ │ ├── activity_call.xml │ │ │ ├── activity_contact_permission_request.xml │ │ │ ├── activity_dailyreminder.xml │ │ │ ├── activity_debug_facebook.xml │ │ │ ├── activity_donate.xml │ │ │ ├── activity_facebook_log_in.xml │ │ │ ├── activity_facebook_profile.xml │ │ │ ├── activity_home.xml │ │ │ ├── activity_namedays.xml │ │ │ ├── activity_person.xml │ │ │ ├── activity_preferences.xml │ │ │ ├── activity_rate_dialog.xml │ │ │ ├── activity_search.xml │ │ │ ├── activity_try.xml │ │ │ ├── activity_upcoming_events_widget_configure__first_frame.xml │ │ │ ├── activity_upcoming_events_widget_configure__start.xml │ │ │ ├── card_bankholiday.xml │ │ │ ├── card_compact_support_heart.xml │ │ │ ├── card_contact_event_full.xml │ │ │ ├── card_full_support_heart.xml │ │ │ ├── card_load_more.xml │ │ │ ├── card_nameday_single.xml │ │ │ ├── card_namedays.xml │ │ │ ├── dialog_birthday_picker.xml │ │ │ ├── dialog_bottom_dialog.xml │ │ │ ├── dialog_donate.xml │ │ │ ├── dialog_pick_image.xml │ │ │ ├── dialog_progress.xml │ │ │ ├── dialog_rate_prompt.xml │ │ │ ├── dialog_support.xml │ │ │ ├── dialog_translate.xml │ │ │ ├── fragment_first.xml │ │ │ ├── fragment_people.xml │ │ │ ├── fragment_upcoming_events.xml │ │ │ ├── merge_avatar_picker_view.xml │ │ │ ├── merge_birthday_picker.xml │ │ │ ├── merge_birthdaypicker_label_view.xml │ │ │ ├── merge_color_imageview.xml │ │ │ ├── merge_compact_cardview.xml │ │ │ ├── merge_contact_permission_required.xml │ │ │ ├── merge_contact_suggestion_view.xml │ │ │ ├── merge_daymarker.xml │ │ │ ├── merge_donation_banner_view.xml │ │ │ ├── merge_dummy_view.xml │ │ │ ├── merge_logo_view.xml │ │ │ ├── merge_memento_toolbar.xml │ │ │ ├── merge_namedaycardview.xml │ │ │ ├── merge_no_contact_events.xml │ │ │ ├── merge_searchbar.xml │ │ │ ├── merge_upcoming_widget_configure_panel.xml │ │ │ ├── merge_upcoming_widget_preview.xml │ │ │ ├── nameday_date.xml │ │ │ ├── names_suggestions.xml │ │ │ ├── navigation_header.xml │ │ │ ├── navigation_new_feature.xml │ │ │ ├── page_person_items.xml │ │ │ ├── preference_list_fragment.xml │ │ │ ├── preference_theme_select.xml │ │ │ ├── row_account.xml │ │ │ ├── row_add_event_contact_event.xml │ │ │ ├── row_add_event_contact_suggestion.xml │ │ │ ├── row_contact.xml │ │ │ ├── row_datatype.xml │ │ │ ├── row_image_option.xml │ │ │ ├── row_nameday_checkbox.xml │ │ │ ├── row_nameday_contact.xml │ │ │ ├── row_nameday_name.xml │ │ │ ├── row_no_search_results.xml │ │ │ ├── row_people.xml │ │ │ ├── row_people_import_from_facebook.xml │ │ │ ├── row_people_no_contacts.xml │ │ │ ├── row_person_action.xml │ │ │ ├── row_person_event.xml │ │ │ ├── row_search_result_contact_event.xml │ │ │ ├── row_suggested_name.xml │ │ │ ├── row_theme_select.xml │ │ │ ├── row_upcoming_events_bankholiday.xml │ │ │ ├── row_upcoming_events_contact_event.xml │ │ │ ├── row_upcoming_events_date_header.xml │ │ │ ├── row_upcoming_events_nameday.xml │ │ │ ├── toolbar_search.xml │ │ │ ├── widget_loading.xml │ │ │ ├── widget_prompt_permissions.xml │ │ │ ├── widget_today.xml │ │ │ ├── widget_today_nocontacts.xml │ │ │ ├── widget_upcoming_events.xml │ │ │ ├── widget_upcoming_events_list_contact_event.xml │ │ │ ├── widget_upcoming_events_list_date.xml │ │ │ ├── widget_upcoming_events_list_nameday.xml │ │ │ └── widget_upcomingevents_list_bankholiday.xml │ │ ├── layout-land/ │ │ │ └── activity_add_event.xml │ │ ├── menu/ │ │ │ ├── menu_date_details.xml │ │ │ ├── menu_nameday_view.xml │ │ │ ├── menu_nav_drawer.xml │ │ │ ├── menu_person_details.xml │ │ │ └── menu_search.xml │ │ ├── raw/ │ │ │ ├── cs_namedays.json │ │ │ ├── gr_namedays.json │ │ │ ├── hu_namedays.json │ │ │ ├── it_namedays.json │ │ │ ├── lv_ext_namedays.json │ │ │ ├── lv_namedays.json │ │ │ ├── ro_namedays.json │ │ │ ├── ru_namedays.json │ │ │ └── sk_namedays.json │ │ ├── transition/ │ │ │ ├── changebounds.xml │ │ │ ├── explode.xml │ │ │ └── fade_and_changebounds.xml │ │ ├── values/ │ │ │ ├── add_event-resources.xml │ │ │ ├── admob.xml │ │ │ ├── animations_dimens.xml │ │ │ ├── arrays.xml │ │ │ ├── attrs.xml │ │ │ ├── available_themes.xml │ │ │ ├── bankholidays_preferences-resources.xml │ │ │ ├── bithday_picker-styles.xml │ │ │ ├── bools.xml │ │ │ ├── bottom_sheet-resources.xml │ │ │ ├── card-resources.xml │ │ │ ├── colors.xml │ │ │ ├── colors_dayslight.xml │ │ │ ├── colors_support.xml │ │ │ ├── contact_cards-resources.xml │ │ │ ├── contact_permission-resources.xml │ │ │ ├── date_details-resources.xml │ │ │ ├── debug_keys.xml │ │ │ ├── dimens-contact_details.xml │ │ │ ├── dimens.xml │ │ │ ├── divider-res.xml │ │ │ ├── donate-resources.xml │ │ │ ├── drawable.xml │ │ │ ├── facebook-resources.xml │ │ │ ├── ids.xml │ │ │ ├── integers.xml │ │ │ ├── main_preferences-resources.xml │ │ │ ├── namedayscreen-resources.xml │ │ │ ├── no_contacts.xml │ │ │ ├── nocontacts_styles.xml │ │ │ ├── people_resources.xml │ │ │ ├── person-resources.xml │ │ │ ├── pref_fragment_dimens.xml │ │ │ ├── pref_fragment_strings.xml │ │ │ ├── search-resources.xml │ │ │ ├── string_keys.xml │ │ │ ├── styles.xml │ │ │ ├── support-resources.xml │ │ │ ├── support-strings.xml │ │ │ ├── themes-strings.xml │ │ │ ├── themes.xml │ │ │ ├── today_widget_strings.xml │ │ │ ├── toolbar-resources.xml │ │ │ ├── upcoming-resources.xml │ │ │ ├── upcoming_widget_dimens.xml │ │ │ ├── upcoming_widget_preview-resources.xml │ │ │ └── widget_upcoming_events-resources.xml │ │ ├── values-el/ │ │ │ ├── bool.xml │ │ │ └── support-strings.xml │ │ ├── values-it/ │ │ │ └── bool.xml │ │ ├── values-land/ │ │ │ ├── bools.xml │ │ │ ├── dimens.xml │ │ │ ├── integers.xml │ │ │ └── toolbar-resources.xml │ │ ├── values-land-v23/ │ │ │ └── themes.xml │ │ ├── values-large/ │ │ │ └── bool.xml │ │ ├── values-sw600dp/ │ │ │ ├── bottom_sheet-resources.xml │ │ │ ├── dimens.xml │ │ │ ├── integers.xml │ │ │ └── toolbar-resources.xml │ │ ├── values-sw720dp/ │ │ │ └── bottom_sheet-resources.xml │ │ ├── values-sw720dp-land/ │ │ │ └── dimens.xml │ │ ├── values-v21/ │ │ │ ├── dimens.xml │ │ │ ├── drawable.xml │ │ │ └── styles.xml │ │ ├── xml/ │ │ │ ├── file_paths.xml │ │ │ ├── preference_dailyreminder.xml │ │ │ ├── preference_debug.xml │ │ │ ├── preference_main.xml │ │ │ ├── widget_info_upcoming_events_list.xml │ │ │ └── widget_info_upcoming_events_simple.xml │ │ └── xml-v26/ │ │ └── preference_dailyreminder.xml │ └── test/ │ └── java/ │ └── com/ │ └── alexstyl/ │ ├── resources/ │ │ └── JavaStrings.kt │ └── specialdates/ │ └── person/ │ ├── CompositeContactActionsProviderTest.kt │ └── PersonInfoViewModelFactoryTest.kt ├── android_wear/ │ ├── .gitignore │ ├── build.gradle │ ├── proguard-rules.pro │ └── src/ │ └── main/ │ ├── AndroidManifest.xml │ ├── java/ │ │ └── com/ │ │ └── alexstyl/ │ │ └── specialdates/ │ │ ├── ContactEventsActivity.java │ │ ├── ContactEventsProviderService.java │ │ ├── DataChangedListenerService.java │ │ └── WearCommunicationService.java │ └── res/ │ ├── layout/ │ │ └── activity_contact_events.xml │ └── values/ │ ├── contact_events-resource.xml │ └── strings.xml ├── build.gradle ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── memento/ │ ├── .gitignore │ ├── build.gradle │ ├── config.yml │ └── src/ │ ├── main/ │ │ └── java/ │ │ ├── android/ │ │ │ └── LruCache.java │ │ └── com/ │ │ └── alexstyl/ │ │ ├── Logger.kt │ │ ├── gsc/ │ │ │ ├── Index.java │ │ │ ├── Sound.kt │ │ │ ├── SoundComparer.kt │ │ │ └── SoundRules.kt │ │ ├── resources/ │ │ │ └── Colors.kt │ │ └── specialdates/ │ │ ├── CrashAndErrorTracker.kt │ │ ├── EventsUpdateTrigger.java │ │ ├── LabelSetter.java │ │ ├── Optional.kt │ │ ├── SoundWordComparator.java │ │ ├── Strings.kt │ │ ├── TimeOfDay.kt │ │ ├── UpcomingEventsView.kt │ │ ├── WordComparator.java │ │ ├── addevent/ │ │ │ ├── AddEventContactEventViewModel.kt │ │ │ ├── AddEventView.kt │ │ │ ├── AddEventViewModelFactory.kt │ │ │ ├── AddEventsPresenter.kt │ │ │ ├── ContactOperations.kt │ │ │ ├── ContactOperationsExecutor.kt │ │ │ ├── ContactsSearch.kt │ │ │ ├── EventIcons.kt │ │ │ ├── MessageDisplayer.kt │ │ │ └── operations/ │ │ │ ├── ContactOperation.kt │ │ │ ├── InsertContact.kt │ │ │ ├── InsertEvent.kt │ │ │ ├── InsertImage.kt │ │ │ └── UpdateContact.kt │ │ ├── analytics/ │ │ │ ├── Analytics.kt │ │ │ ├── Screen.kt │ │ │ └── Widget.kt │ │ ├── contact/ │ │ │ ├── Contact.kt │ │ │ ├── ContactCache.kt │ │ │ ├── ContactNotFoundException.kt │ │ │ ├── ContactSource.java │ │ │ ├── Contacts.kt │ │ │ ├── ContactsProvider.kt │ │ │ ├── ContactsProviderSource.kt │ │ │ ├── DisplayName.kt │ │ │ └── Names.kt │ │ ├── dailyreminder/ │ │ │ ├── BankHolidayNotificationViewModel.kt │ │ │ ├── ContactActionViewModel.kt │ │ │ ├── ContactEventNotificationViewModel.kt │ │ │ ├── DailyReminderNotifier.kt │ │ │ ├── DailyReminderPresenter.kt │ │ │ ├── DailyReminderScheduler.kt │ │ │ ├── DailyReminderUserSettings.kt │ │ │ ├── DailyReminderView.kt │ │ │ ├── DailyReminderViewModel.kt │ │ │ ├── DailyReminderViewModelFactory.kt │ │ │ ├── NamedaysNotificationViewModel.kt │ │ │ ├── NotificationViewModel.kt │ │ │ └── SummaryNotificationViewModel.kt │ │ ├── date/ │ │ │ ├── ContactEvent.kt │ │ │ ├── Date.kt │ │ │ ├── DateAndTime.kt │ │ │ ├── DateComparator.java │ │ │ ├── DateLabelCreator.kt │ │ │ ├── DateParseException.kt │ │ │ ├── DateParser.kt │ │ │ ├── Dates.kt │ │ │ ├── MonthInt.java │ │ │ ├── Months.java │ │ │ └── TimePeriod.kt │ │ ├── donate/ │ │ │ ├── DonateMonitor.kt │ │ │ ├── Donation.java │ │ │ ├── DonationCallbacks.java │ │ │ └── DonationService.java │ │ ├── events/ │ │ │ ├── Event.kt │ │ │ ├── SettingsPresenter.kt │ │ │ ├── bankholidays/ │ │ │ │ ├── BankHoliday.kt │ │ │ │ ├── BankHolidayProvider.kt │ │ │ │ ├── BankHolidaysUserSettings.kt │ │ │ │ └── GreekBankHolidaysCalculator.java │ │ │ ├── database/ │ │ │ │ └── EventTypeId.java │ │ │ ├── namedays/ │ │ │ │ ├── NameCelebrations.kt │ │ │ │ ├── NamedayBundle.java │ │ │ │ ├── NamedayDatabaseRefresher.kt │ │ │ │ ├── NamedayLocale.java │ │ │ │ ├── NamedayUserSettings.java │ │ │ │ ├── NamedaysList.java │ │ │ │ ├── NamesInADate.kt │ │ │ │ ├── activity/ │ │ │ │ │ ├── CelebratingContactViewModel.kt │ │ │ │ │ ├── NamedayScreenViewModel.kt │ │ │ │ │ ├── NamedayScreenViewType.java │ │ │ │ │ ├── NamedaysInADayPresenter.kt │ │ │ │ │ ├── NamedaysOnADayView.kt │ │ │ │ │ ├── NamedaysViewModel.kt │ │ │ │ │ └── NamedaysViewModelFactory.kt │ │ │ │ └── calendar/ │ │ │ │ ├── EasternNameday.java │ │ │ │ ├── EasternNamedaysExtractor.java │ │ │ │ ├── NamedayCalendar.java │ │ │ │ ├── OrthodoxEasterCalculator.java │ │ │ │ └── resource/ │ │ │ │ ├── CharacterNode.java │ │ │ │ ├── GreekNamedays.java │ │ │ │ ├── GreekSpecialNamedays.java │ │ │ │ ├── NamedayCalendarProvider.java │ │ │ │ ├── NamedayJSON.java │ │ │ │ ├── NamedayJSONParser.java │ │ │ │ ├── NamedayJSONProvider.java │ │ │ │ ├── NamedayJSONResourceLoader.kt │ │ │ │ ├── Node.java │ │ │ │ ├── RomanianEasterSpecialCalculator.kt │ │ │ │ ├── RomanianNamedays.kt │ │ │ │ ├── RomanianSpecialNamedays.java │ │ │ │ ├── SoundNode.kt │ │ │ │ ├── SpecialGreekNamedaysCalculator.java │ │ │ │ ├── SpecialNamedays.java │ │ │ │ └── SpecialNamedaysHandlerFactory.java │ │ │ └── peopleevents/ │ │ │ ├── ClosestEventsComparator.java │ │ │ ├── CompositePeopleEventsProvider.kt │ │ │ ├── ContactEventsOnADate.kt │ │ │ ├── CustomEventType.kt │ │ │ ├── EventType.kt │ │ │ ├── NoEventsFoundException.kt │ │ │ ├── PeopleDynamicNamedaysProvider.kt │ │ │ ├── PeopleEventsPersister.kt │ │ │ ├── PeopleEventsProvider.kt │ │ │ ├── PeopleEventsRepository.kt │ │ │ ├── PeopleEventsStaticEventsRefresher.kt │ │ │ ├── PeopleEventsUpdater.kt │ │ │ ├── ShortDateLabelCreator.java │ │ │ ├── StandardEventType.java │ │ │ ├── UpcomingEventsSettings.kt │ │ │ └── UpcomingEventsViewRefresher.kt │ │ ├── facebook/ │ │ │ ├── FacebookImagePath.kt │ │ │ ├── FacebookUserSettings.kt │ │ │ ├── UserCredentials.java │ │ │ └── friendimport/ │ │ │ ├── CalendarFetcherException.java │ │ │ ├── CalendarLoader.java │ │ │ ├── ContactEventSerialiser.java │ │ │ ├── FacebookBirthdaysProvider.java │ │ │ ├── FacebookCalendarLoader.java │ │ │ ├── FacebookContactFactory.java │ │ │ └── InvalidFacebookContactException.java │ │ ├── people/ │ │ │ ├── FacebookImportViewModel.kt │ │ │ ├── NoContactsViewModel.kt │ │ │ ├── PeoplePresenter.kt │ │ │ ├── PeopleRowViewModel.kt │ │ │ ├── PeopleView.java │ │ │ ├── PeopleViewHolderListener.java │ │ │ ├── PeopleViewModelFactory.kt │ │ │ └── PersonViewModel.kt │ │ ├── permissions/ │ │ │ └── MementoPermissions.kt │ │ ├── person/ │ │ │ ├── AgeCalculator.kt │ │ │ ├── ContactActions.kt │ │ │ ├── ContactEventViewModel.kt │ │ │ ├── EventViewModelFactory.kt │ │ │ ├── PersonDetailItem.kt │ │ │ └── StarSign.kt │ │ ├── search/ │ │ │ ├── ContactEventLabelCreator.kt │ │ │ ├── ContactWithEvents.java │ │ │ ├── NameFilter.java │ │ │ ├── NameMatcher.kt │ │ │ └── PeopleEventsSearch.java │ │ ├── ui/ │ │ │ └── widget/ │ │ │ └── LetterPainter.kt │ │ ├── upcoming/ │ │ │ ├── AnnualDate.kt │ │ │ ├── BankHolidayViewModel.kt │ │ │ ├── CompositeUpcomingEventsProvider.kt │ │ │ ├── ContactViewModelFactory.kt │ │ │ ├── DateHeaderViewModel.kt │ │ │ ├── MonthLabels.java │ │ │ ├── UpcomingContactEventViewModel.kt │ │ │ ├── UpcomingDateStringCreator.kt │ │ │ ├── UpcomingEventRowViewModelFactory.kt │ │ │ ├── UpcomingEventsAdRules.kt │ │ │ ├── UpcomingEventsFreeUserAdRules.kt │ │ │ ├── UpcomingEventsPresenter.kt │ │ │ ├── UpcomingEventsProvider.kt │ │ │ ├── UpcomingListMVPView.kt │ │ │ ├── UpcomingNamedaysViewModel.kt │ │ │ ├── UpcomingRowViewModel.kt │ │ │ ├── UpcomingRowViewModelsBuilder.kt │ │ │ ├── UpcomingRowViewType.java │ │ │ └── widget/ │ │ │ ├── list/ │ │ │ │ └── NoAds.kt │ │ │ └── today/ │ │ │ ├── PercentToValueConverter.java │ │ │ ├── RecentPeopleEventsPresenter.kt │ │ │ └── RecentPeopleEventsView.kt │ │ └── util/ │ │ └── HashMapList.kt │ └── test/ │ ├── java/ │ │ └── com/ │ │ └── alexstyl/ │ │ ├── TestColors.kt │ │ ├── gsc/ │ │ │ ├── SoundComparerTest.kt │ │ │ └── SoundTest.kt │ │ └── specialdates/ │ │ ├── JavaStrings.kt │ │ ├── NamesTest.kt │ │ ├── OptionalTest.java │ │ ├── TestContactEventsBuilder.kt │ │ ├── TestDateLabelCreator.kt │ │ ├── addevent/ │ │ │ ├── AddEventsPresenterTest.kt │ │ │ ├── JavaEventIcons.kt │ │ │ ├── JavaMessageDisplayer.kt │ │ │ └── ui/ │ │ │ ├── ContactSourcesStubs.kt │ │ │ └── ContactsSearchTest.kt │ │ ├── contact/ │ │ │ ├── ContactCacheTest.java │ │ │ ├── ContactFixture.java │ │ │ ├── ContactTest.java │ │ │ └── DisplayNameTest.java │ │ ├── date/ │ │ │ └── ContactEventTest.java │ │ ├── events/ │ │ │ ├── ContactActionTest.java │ │ │ ├── DateTest.java │ │ │ ├── ShortDateLabelCreatorTest.java │ │ │ ├── StandardEventTypeTest.java │ │ │ ├── bankholidays/ │ │ │ │ └── BankHolidayProviderTest.java │ │ │ ├── namedays/ │ │ │ │ ├── NamedaysListTest.java │ │ │ │ ├── OrthodoxEasterCalculatorTest.java │ │ │ │ ├── RomanianNamedaysTest.java │ │ │ │ ├── activity/ │ │ │ │ │ └── NamedaysInADayPresenterTest.kt │ │ │ │ └── calendar/ │ │ │ │ └── resource/ │ │ │ │ ├── CharacterNodeTest.java │ │ │ │ ├── GreeklishParserTest.java │ │ │ │ ├── NamedayJSONParserTest.java │ │ │ │ ├── RomanianEasterSpecialCalculatorTest.kt │ │ │ │ ├── SoundNodeTest.kt │ │ │ │ ├── TestJSONResourceLoader.kt │ │ │ │ └── TestNamedayCalendarBuilder.java │ │ │ └── peopleevents/ │ │ │ ├── ClosestEventsComparatorTest.java │ │ │ ├── CompositePeopleEventsProviderTest.kt │ │ │ ├── ContactEventsOnADateTest.kt │ │ │ └── PeopleDynamicNamedaysProviderTest.kt │ │ ├── facebook/ │ │ │ └── friendimport/ │ │ │ ├── FacebookBirthdaysProviderTest.java │ │ │ ├── FacebookContactFactoryTest.java │ │ │ ├── MockCalendarLoader.java │ │ │ └── SystemLogTracker.kt │ │ ├── person/ │ │ │ ├── AgeCalculatorTest.kt │ │ │ └── StarSignTest.kt │ │ ├── search/ │ │ │ ├── ContactEventTestBuilder.java │ │ │ ├── EventLabelCreatorTest.java │ │ │ ├── NameFilterTest.java │ │ │ ├── NameMatcherTest.java │ │ │ └── PeopleEventsSearchTest.kt │ │ ├── timeofday/ │ │ │ └── TimeOfDayTest.java │ │ ├── upcoming/ │ │ │ ├── MonthLabelsTest.java │ │ │ ├── TimePeriodTest.java │ │ │ ├── UpcomingEventsPresenterTest.kt │ │ │ ├── UpcomingRowViewModelsBuilderTest.kt │ │ │ └── widget/ │ │ │ └── today/ │ │ │ └── PercentToValueConverterTest.java │ │ └── util/ │ │ ├── DateParserTest.kt │ │ └── HashMapListTest.java │ └── resources/ │ └── gr_namedays.json ├── secret.gradle ├── settings.gradle ├── team-props/ │ ├── android-code-quality.gradle │ ├── static-analysis/ │ │ ├── checkstyle-modules.xml │ │ ├── checkstyle-suppressions.xml │ │ ├── detekt-config.yml │ │ ├── findbugs-excludes.xml │ │ ├── lint-config.xml │ │ └── pmd-rules.xml │ └── static-analysis.gradle └── versions.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/ci-scripts/accept-android-licenses.sh ================================================ #!/usr/bin/env bash export LICENSES_PATH="$ANDROID_HOME/licenses" export ANDROID_SDK_LICENSE_PATH="$LICENSES_PATH/android-sdk-license" export ANDROID_SDK_LICENSE_CONTENTS=$'\n8933bad161af4178b1185d1a37fbf41ea5269c55' if [ ! -e ${ANDROID_SDK_LICENSE_PATH} ]; then echo "Android SDK license acceptance not found in '$LICENSES_PATH', creating it..." mkdir "$LICENSES_PATH" || true echo -e "$ANDROID_SDK_LICENSE_CONTENTS" > "$ANDROID_SDK_LICENSE_PATH" echo "Done." else echo "No need to create license acceptance file, already found in: $LICENSES_PATH/" fi ================================================ FILE: .circleci/ci-scripts/ensure-sdkmanager.sh ================================================ #!/usr/bin/env bash export TOOLS_BIN_PATH="$ANDROID_HOME/tools/bin" export SDKMANAGER_PATH="$TOOLS_BIN_PATH/sdkmanager" if [ ! -e ${SDKMANAGER_PATH} ]; then echo "sdkmanager tool not found in '$SDKMANAGER_PATH', updating Android SDK tools..." android update sdk --no-ui --all --filter tools echo "Android SDK tools updated." else echo "No need to update Android SDK tools, sdkmanager found in: $TOOLS_BIN_PATH/" fi ================================================ FILE: .circleci/ci-scripts/mock-google-services.json ================================================ { "project_info": { "project_id": "mockproject-1234", "project_number": "123456789000", "name": "FirebaseQuickstarts", "firebase_url": "https://mockproject-1234.firebaseio.com" }, "client": [ { "client_info": { "mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063", "client_id": "android:com.google.samples.quickstart.admobexample", "client_type": 1, "android_client_info": { "package_name": "com.alexstyl.specialdates", "certificate_hash": [] } }, "oauth_client": [ { "client_id": "123456789000-hjugbg6ud799v4c49dim8ce2usclthar.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "com.alexstyl.specialdates", "certificate_hash": "4C20644DE36B8F89D25650C7D1FF9FBAE650FDF7" } }, { "client_id": "123456789000-e4uksm38sne0bqrj6uvkbo4oiu4hvigl.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { "current_key": "AIzbSzCn1N6LWIe6wthYyrgUUSAlUsdqMb-wvTo" } ], "services": { "analytics_service": { "status": 1 }, "cloud_messaging_service": { "status": 2, "apns_config": [] }, "appinvite_service": { "status": 2, "other_platform_oauth_client": [ { "client_id": "123456789000-e4uksm38sne0bqrj6uvkbo4oiu4hvigl.apps.googleusercontent.com", "client_type": 3 } ] }, "google_signin_service": { "status": 2 }, "ads_service": { "status": 2, "test_banner_ad_unit_id": "ca-app-pub-3940256099942544/6300978111", "test_interstitial_ad_unit_id": "ca-app-pub-3940256099942544/1033173712" } } }, { "client_info": { "mobilesdk_app_id": "1:123456789000:android:f1bf012572b04063", "client_id": "android:com.google.samples.quickstart.admobexample", "client_type": 1, "android_client_info": { "package_name": "com.alexstyl.specialdates.pro", "certificate_hash": [] } }, "oauth_client": [ { "client_id": "123456789000-hjugbg6ud799v4c49dim8ce2usclthar.apps.googleusercontent.com", "client_type": 1, "android_info": { "package_name": "com.alexstyl.specialdates.pro", "certificate_hash": "4C20644DE36B8F89D25650C7D1FF9FBAE650FDF7" } }, { "client_id": "123456789000-e4uksm38sne0bqrj6uvkbo4oiu4hvigl.apps.googleusercontent.com", "client_type": 3 } ], "api_key": [ { "current_key": "AIzbSzCn1N6LWIe6wthYyrgUUSAlUsdqMb-wvTo" } ], "services": { "analytics_service": { "status": 1 }, "cloud_messaging_service": { "status": 2, "apns_config": [] }, "appinvite_service": { "status": 2, "other_platform_oauth_client": [ { "client_id": "123456789000-e4uksm38sne0bqrj6uvkbo4oiu4hvigl.apps.googleusercontent.com", "client_type": 3 } ] }, "google_signin_service": { "status": 2 }, "ads_service": { "status": 2, "test_banner_ad_unit_id": "ca-app-pub-3940256099942544/6300978111", "test_interstitial_ad_unit_id": "ca-app-pub-3940256099942544/1033173712" } } } ], "client_info": [], "ARTIFACT_VERSION": "1" } ================================================ FILE: .circleci/config.yml ================================================ version: 2 jobs: build: docker: - image: circleci/android:api-27-alpha environment: ANDROID_HOME: /opt/android/sdk APPLICATION_ID: com.alexstyl.specialdates steps: - checkout # Restore cached dependencies (if any) - restore_cache: key: jars-{{ checksum "build.gradle" }}-{{ checksum "android_mobile/build.gradle" }} # Prepare the container for the build - run: name: Accept Android SDK license command: .circleci/ci-scripts/accept-android-licenses.sh - run: name: Ensure Android SDK install is up-to-date command: .circleci/ci-scripts/ensure-sdkmanager.sh # Run the main job command, delegating to Gradle - run: name: Run Gradle :check command command: ./gradlew check --stacktrace --continue # Store all the downloaded dependencies in the CI cache - save_cache: paths: # Android SDK - /usr/local/android-sdk-linux/tools - /usr/local/android-sdk-linux/platform-tools - /usr/local/android-sdk-linux/build-tools - /usr/local/android-sdk-linux/licenses - /usr/local/android-sdk-linux/extras/google/m2repository # Gradle dependencies - ~/.gradle key: jars-{{ checksum "build.gradle" }}-{{ checksum "android_mobile/build.gradle" }} # Collect static analysis reports as build artifacts - store_artifacts: path: android_mobile/build/reports destination: reports # Collect JUnit test results - store_test_results: path: android_mobile/build/test-results ================================================ FILE: .github/CONTRIBUTING.md ================================================ ## Ways of contributing There are a few ways of contributing into the project: 1. Bug fixes 2. Check the [Issues page](https://github.com/alexstyl/MementoNamedays/issues) and resolve any issues you can find 3. Code refactor. The project is fairly old, so there is huge room for improvement :) 4. Introduce any interesting functionality you might need. Please do let me know before doing this first. There might be a chance that a feature you might want to include be really specific to your needs and not fit the overall style of the app. For any questions on features PR, email me at alexstyl.dev@gmail.com 5. Translate the app [by visiting OneSky](https://memento.oneskyapp.com/admin/project/dashboard/project/85177) ## Creating Pull Requests (PR) The repo contains two main branches: * `master` which contains all changes currently available to the Play Store. * `develop` which contains all changes to be uploaded in the next version of Memento. 1. Create a branch from `develop`. 2. Make any modifications required. 3. When done, open a PR of that branch against develop. Make sure to consider testing the code you push. Do not create huge PRs. If a PR grows big, make sure to create a new branch with those changes, and create a PR against your initial `feature` branch. The bigger the PR, the less chances of it getting merged on time. ## Reporting Issues In case you found a bug in the app, or some bits in the code base that can cause issues, you can open an Issue so that someone can fix it. If it is a bug, make sure to write down all the steps it took to replicate it and (if possible) add a GIF showcasing the bug. You can record your screen with the help of Android Studio ([see this video](https://youtu.be/uOvL4TXzOm4?t=39s)). Keep in mind that video recording does not work on Emulators, so you will need a real device for that. After you are done recording, convert the video into a GIF. I usually use this [website (video to gif)](http://image.online-convert.com/convert-to-gif). Make sure to attach the GIF into your Issue :) ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ #### Short Description _Give a brief description of what the issue is_ ##### Steps to reproduce _This is just an example of how to write the steps_ 1. Open app from home screen's icon 2. Select any day on the list 3. Tap on a contact's card ##### Resulted in App crashes ##### Expected Results Display the contact details of the selected contact ##### Video/Screenshots (if no visual change, just delete) {before image} ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ #### Description _Give a brief explanation of what is the problem/feature you are working on and what this PR contains_ ##### Test(s) added _yes|no_ and reasoning why ##### Screenshots (if no visual change, just delete) | Before | After | | ------ | ----- | | {before image} | {after image} | ================================================ FILE: .gitignore ================================================ # Built application files *.apk *.ap_ # Files for the ART/Dalvik VM *.dex # Java class files *.class # Generated files bin/ gen/ out/ # Gradle files .gradle/ build/ # Local configuration file (sdk path, etc) local.properties # Proguard folder generated by Eclipse proguard/ # Log Files *.log # Android Studio Navigation editor temp files .navigation/ # Android Studio captures folder captures/ # Intellij .idea/ *.iml *.iws # Keystore files *.jks # Mac .DS_Store # secrets google-services.json ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2016 Alex Styl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: PEOPLE.md ================================================ Memento Calendar could not be in the amazing shape it is today if it wasn't for the following amazing people (alphabetically sorted): * [auricgoldfinger](https://github.com/alexstyl/Memento-Calendar/pulls?utf8=%E2%9C%93&q=author%3Aauricgoldfinger) for the Big Text notification implementation * [Andreas Sfakianakis](https://github.com/exaila) for the creation of the greeklish sound converter * [Chrysa Papadopoulou](https://github.com/alexstyl/Memento-Calendar/pulls?utf8=%E2%9C%93&q=author%3Apchrysa) for the italian namedays * [Daniele Bonaldo](https://github.com/alexstyl/Memento-Calendar/pulls?utf8=%E2%9C%93&q=author%3Adanybony) for the Android Wear implementation * [Daniele Conti](https://github.com/alexstyl/Memento-Calendar/pulls?utf8=%E2%9C%93&q=author%3Afourlastor) for performance improvements and kotlin enhancements * [Thanos Psaridis (Fisherman)](https://github.com/alexstyl/Memento-Calendar/pulls?utf8=%E2%9C%93&q=author%3AThanosFisherman) for bug fixing * [Qi Qu](https://github.com/alexstyl/Memento-Calendar/pulls?utf8=%E2%9C%93&q=author%3Aqqipp) for the updated Memento Calendar app icon * [madlymad](https://github.com/alexstyl/Memento-Calendar/pulls?utf8=%E2%9C%93&q=author%3Amadlymad) for improvements in the CI and madfixes You can find all contributors at https://github.com/alexstyl/Memento-Calendar/graphs/contributors Big thanks to all the translators via [OneSky](https://memento.oneskyapp.com/collaboration/project/85177): * Andrejs Kotovs for the 🇱🇻 Latvian translations * Aggela Styl for the 🇫🇷 French translations * Bert for the 🇳🇱 Dutch translations * Denis Mone for the 🇬🇷 Greek translations (corrections and improvements) * Giangi for the 🇮🇹 Italian translations * janfelcman for the 🇨🇿 Czech translations ================================================ FILE: README.md ================================================ # Archived The repo is now archived and no new commits will take place. Thank for your support and contributions. # Memento Calendar for Android [![alt text](https://travis-ci.org/alexstyl/Memento-Calendar.svg?branch=master "Check the build status on Travis CI")](https://travis-ci.org/alexstyl/Memento-Calendar) Memento Calendar is a modern namedays app for Android. This repository contains the source code of Memento Calendar. You can get started by having a look at the project's wiki. It contains some information about how to get Memento up and running on your machine and other useful info. This repo is open for PRs and they are more than welcome! Have a look [at the wiki page to see how to contribute](https://github.com/alexstyl/Memento-Calendar/wiki/How-to-contribute). [![alt text](http://developer.android.com/images/brand/en_app_rgb_wo_60.png "Download Memento Calendar from the Play Store")](https://play.google.com/store/apps/details?id=com.alexstyl.specialdates) ## Project Goal Memento Calendar is my pet project/playground in which I experiment with various platform features development patterns and share my foundings with the community my foundings via blog posts and talks. Memento started off as a side project app back in 2014 and has been on development on and off. Current goal of the project is to split out the business logic of the app from the app logic so that it could potentially be ported into other platforms with the help of Kotlin. ## Modules The app is split into multiple modules. The business logic of the app can be found in the **memento** module. There are three other Android Modules: ### android_common This is the shared resources across all Android specific modules. It depends on *memento*. ### android_wear This is the Android Wear module. It depends on *android_common*. ### android_mobile This is the Android mobile app module. It depends on *android_common*. ## Architecture The Model-View-Presenter is used in order to architecture the app. **Presenters** are platform agnostic and live in the **memento** module, in order to be able to be used across all platforms. They contain the core logic of forwarding *Models* to the *Views*. It is up for the specific platform component to create a View **Views** are responsible displaying information back to the user. For each view there is one interface that lives in the memento module. A view is not to be confused with Android's View classes, Activities or Fragments. **Models** contain the minimum amount of information needed to render the information on the screen. I did a talk in the GDG Android Athens about the structure of Memento Calendar. The talk is in Greek, but the slides contain more information about the structure [(see the slides)](https://speakerdeck.com/alexstyl/the-journey-towards-a-platform-agnostic-codebase). ## License ``` MIT License Copyright (c) 2016 Alex Styl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` ================================================ FILE: android_common/build.gradle ================================================ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply from: '../versions.gradle' android { compileSdkVersion androidCompileSdkVersion buildToolsVersion androidBuildToolsVersion // All these unused resources are actually used by other modules lintOptions { abortOnError false } defaultConfig { minSdkVersion 16 buildConfigField 'String', 'MIXPANEL_TOKEN', '\"' + mixpanelToken + "\"" } } dependencies { api project(path: ':memento') api 'com.mixpanel.android:mixpanel-android:4.9.2' api "com.android.support:support-annotations:$android_support_version" api 'net.danlew:android.joda:2.9.9' api "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" api 'io.reactivex.rxjava2:rxandroid:2.0.1' } ================================================ FILE: android_common/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android_common/src/main/java/com/alexstyl/android/AndroidLogger.kt ================================================ package com.alexstyl.android import android.util.Log import com.alexstyl.Logger class AndroidLogger : Logger { override fun debug(message: String) { Log.d(this.javaClass.simpleName, message) } override fun warning(message: String) { Log.w(this.javaClass.simpleName, message) } } ================================================ FILE: android_common/src/main/java/com/alexstyl/android/Version.kt ================================================ package com.alexstyl.android import android.os.Build object Version { fun hasJellyBean(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN } fun hasKitKat(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT } fun hasLollipop(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP } fun hasMarshmallow(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M } fun hasOreo(): Boolean { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O } } ================================================ FILE: android_common/src/main/java/com/alexstyl/android/ViewVisibility.java ================================================ package com.alexstyl.android; import android.support.annotation.IntDef; import android.view.View; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @Retention(RetentionPolicy.SOURCE) @IntDef({ View.VISIBLE, View.INVISIBLE, View.GONE }) public @interface ViewVisibility { } ================================================ FILE: android_common/src/main/java/com/alexstyl/android/preferences/widget/TimePreference.java ================================================ package com.alexstyl.android.preferences.widget; import android.content.Context; import android.content.res.TypedArray; import android.preference.DialogPreference; import android.preference.PreferenceManager; import android.text.format.DateFormat; import android.util.AttributeSet; import android.view.View; import android.widget.TimePicker; import com.alexstyl.specialdates.common.R; public class TimePreference extends DialogPreference { private int lastHour = 0; private int lastMinute = 0; private TimePicker picker; public TimePreference(Context ctx, AttributeSet attrs) { super(ctx, attrs); setDialogTitle(ctx.getString(R.string.set_time)); setPositiveButtonText(ctx.getString(R.string.set)); setNegativeButtonText(null); } @Override protected View onCreateDialogView() { picker = new TimePicker(getContext()); picker.setIs24HourView(DateFormat.is24HourFormat(getContext())); return picker; } @Override protected void onBindDialogView(View v) { super.onBindDialogView(v); picker.setCurrentHour(lastHour); picker.setCurrentMinute(lastMinute); } @Override protected void onDialogClosed(boolean positiveResult) { super.onDialogClosed(positiveResult); if (positiveResult) { lastHour = picker.getCurrentHour(); lastMinute = picker.getCurrentMinute(); int[] time = new int[2]; time[0] = lastHour; time[1] = lastMinute; if (callChangeListener(time)) { persistString(time[0] + ":" + time[1]); } } } @Override protected Object onGetDefaultValue(TypedArray a, int index) { return (a.getString(index)); } @Override protected void onSetInitialValue(boolean restoreValue, Object defaultValue) { String time; if (restoreValue) { if (defaultValue == null) { time = PreferenceManager.getDefaultSharedPreferences(getContext()).getString( getKey(), "00:00"); } else { time = getPersistedString(defaultValue.toString()); } } else { time = defaultValue.toString(); } lastHour = getHour(time); lastMinute = getMinute(time); } private static int getHour(String time) { String[] pieces = time.split(":"); return Integer.parseInt(pieces[0]); } private static int getMinute(String time) { String[] pieces = time.split(":"); return (Integer.parseInt(pieces[1])); } } ================================================ FILE: android_common/src/main/java/com/alexstyl/android/widget/AppWidgetId.java ================================================ package com.alexstyl.android.widget; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * A id of a Widget */ @Retention(RetentionPolicy.SOURCE) public @interface AppWidgetId { } ================================================ FILE: android_common/src/main/java/com/alexstyl/resources/AndroidDimensionResources.java ================================================ package com.alexstyl.resources; import android.content.res.Resources; import android.support.annotation.DimenRes; final class AndroidDimensionResources implements DimensionResources { private final Resources resources; AndroidDimensionResources(Resources resources) { this.resources = resources; } @Override public int getPixelSize(@DimenRes int id) { return resources.getDimensionPixelSize(id); } } ================================================ FILE: android_common/src/main/java/com/alexstyl/resources/DimensionResources.java ================================================ package com.alexstyl.resources; import android.support.annotation.DimenRes; import android.support.annotation.Px; public interface DimensionResources { @Px int getPixelSize(@DimenRes int id); } ================================================ FILE: android_common/src/main/java/com/alexstyl/specialdates/AndroidStrings.kt ================================================ package com.alexstyl.specialdates import android.content.res.Resources import com.alexstyl.specialdates.common.R import com.alexstyl.specialdates.events.namedays.NamedayLocale import com.alexstyl.specialdates.events.peopleevents.EventType import com.alexstyl.specialdates.events.peopleevents.StandardEventType import com.alexstyl.specialdates.person.StarSign class AndroidStrings(private val resources: Resources) : Strings { override fun contactAddedFailed(): String = resources.getString(R.string.failed_to_add_contact) override fun contactAdded(): String = resources.getString(R.string.contact_added) override fun contactUpdated(): String = resources.getString(R.string.contact_updated) override fun contactUpdateFailed(): String = resources.getString(R.string.failed_to_update_contact) override fun dontForgetToSendWishes(): String = resources.getString(R.string.Dont_forget_to_send_your_wishes) override fun call(): String = resources.getString(R.string.Call) override fun sendWishes(): String = resources.getString(R.string.Send_wishes) override fun bankholidaySubtitle(): String = resources.getString(R.string.Bank_holiday_subtitle) override fun contacts(): String = resources.getString(R.string.contacts) override fun namedays(): String = resources.getString(R.string.namedays) override fun bankholidays(): String = resources.getString(R.string.Bank_holidays) override fun dailyReminder(): String = resources.getString(R.string.daily_reminder) override fun postOnFacebook(): String = resources.getString(R.string.Post_on_Facebook) override fun facebook(): String = resources.getString(R.string.Facebook) override fun facebookMessenger(): String = resources.getString(R.string.facebook_messenger) override fun viewConversation(): String = resources.getString(R.string.View_conversation) override fun nameOf(starSign: StarSign): String = when (starSign) { StarSign.AQUARIUS -> resources.getString(R.string.starsigns_aquarius) StarSign.PISCES -> resources.getString(R.string.starsigns_pisces) StarSign.ARIES -> resources.getString(R.string.starsigns_aries) StarSign.TAURUS -> resources.getString(R.string.starsigns_taurus) StarSign.GEMINI -> resources.getString(R.string.starsigns_gemini) StarSign.CANCER -> resources.getString(R.string.starsigns_cancer) StarSign.LEO -> resources.getString(R.string.starsigns_leo) StarSign.VIRGO -> resources.getString(R.string.starsigns_virgo) StarSign.LIBRA -> resources.getString(R.string.starsigns_libra) StarSign.SCORPIO -> resources.getString(R.string.starsigns_scorpio) StarSign.SAGITTARIUS -> resources.getString(R.string.starsigns_sagittarius) StarSign.CAPRICORN -> resources.getString(R.string.starsigns_capricorn) } override fun turnsAge(age: Int): String = resources.getString(R.string.turns_age, age); override fun inviteFriend(): String = resources.getString(R.string.Invite_friend) override fun todaysNamedays(numberOfNamedays: Int): String = resources.getQuantityString(R.plurals.todays_nameday, numberOfNamedays) override fun donateAmount(amount: String): String = resources.getString(R.string.donation_donate_amount, amount) override fun eventOnDate(eventLabel: String, dateLabel: String): String = resources.getString(R.string.eventlabel_on_dateLabel, eventLabel, dateLabel) override fun appName(): String = resources.getString(R.string.app_name) override fun shareText(): String = resources.getString(R.string.share_text) override fun today(): String = resources.getString(R.string.today) override fun tomorrow(): String = resources.getString(R.string.tomorrow) override fun todayCelebrateTwo(nameOne: String, nameTwo: String): String = resources.getString(R.string.today_celebrates_two, nameOne, nameTwo) override fun todayCelebrateMany(name: String, numberLeft: Int): String = resources.getString(R.string.today_celebrates_many, name, numberLeft) override fun nameOfEvent(event: EventType): String = when (event) { StandardEventType.BIRTHDAY -> resources.getString(R.string.birthday) StandardEventType.NAMEDAY -> resources.getString(R.string.nameday) StandardEventType.ANNIVERSARY -> resources.getString(R.string.Anniversary) StandardEventType.OTHER -> resources.getString(R.string.Other) StandardEventType.CUSTOM -> resources.getString(R.string.Custom) else -> { throw IllegalStateException("$event has no name") } } override fun localeName(locale: NamedayLocale): String = when (locale) { NamedayLocale.GREEK -> resources.getString(R.string.Greek) NamedayLocale.ROMANIAN -> resources.getString(R.string.Romanian) NamedayLocale.RUSSIAN -> resources.getString(R.string.Russian) NamedayLocale.LATVIAN -> resources.getString(R.string.Latvian_Traditional) NamedayLocale.LATVIAN_EXTENDED -> resources.getString(R.string.Latvian_Extended) NamedayLocale.SLOVAK -> resources.getString(R.string.Slovak) NamedayLocale.ITALIAN -> resources.getString(R.string.Italian) NamedayLocale.CZECH -> resources.getString(R.string.Czech) NamedayLocale.HUNGARIAN -> resources.getString(R.string.Hungarian) } override fun viewFacebookProfile(): String = resources.getString(R.string.View_Facebook_Profile) override fun importFromFacebook(): String = resources.getString(R.string.Import_from_Facebook) } ================================================ FILE: android_common/src/main/java/com/alexstyl/specialdates/TextViewLabelSetter.java ================================================ package com.alexstyl.specialdates; import android.widget.TextView; public class TextViewLabelSetter implements LabelSetter { private final TextView textView; public TextViewLabelSetter(TextView textView) { this.textView = textView; } @Override public void setLabel(String text) { textView.setText(text); } } ================================================ FILE: android_common/src/main/java/com/alexstyl/specialdates/date/IntentDateExtensions.kt ================================================ package com.alexstyl.specialdates.date import android.content.Intent val EXTRA_DAY_OF_MONTH = "extra:day_of_month" val EXTRA_MONTH = "extra:month" val EXTRA_YEAR = "extra:year" fun Intent.putExtraDate(date: Date): Intent { return putExtra(EXTRA_DAY_OF_MONTH, date.dayOfMonth) .putExtra(EXTRA_MONTH, date.month) .putExtra(EXTRA_YEAR, date.year) } fun Intent.getDateExtraOrThrow(): Date { val dayOfMonth = getExtraOrThrow(this, EXTRA_DAY_OF_MONTH) @MonthInt val month = getExtraOrThrow(this, EXTRA_MONTH) val year = getExtraOrThrow(this, EXTRA_YEAR) return Date.on(dayOfMonth, month, year) } private fun getExtraOrThrow(intent: Intent, extra: String): Int { val intExtra = intent.getIntExtra(extra, -1) if (intExtra == -1) { throw IllegalArgumentException("Passing Intent did not include extra [$extra]") } return intExtra } ================================================ FILE: android_common/src/main/java/com/alexstyl/specialdates/events/database/EventColumns.java ================================================ package com.alexstyl.specialdates.events.database; public interface EventColumns { /** * A value of {@link EventTypeId} that indicates the type of event */ String EVENT_TYPE = "event_type"; /** * The id of the same event stored in the device's default database *

Will be set to -1 if not available

*/ String DEVICE_EVENT_ID = "device_event_id"; String DATE = "date"; String SOURCE = "source"; /** * A value to indicate whether the user is interested in seeing this event. *

1 for visible, 0 for not-visible

*/ String VISIBLE = "visible"; } ================================================ FILE: android_common/src/main/java/com/alexstyl/specialdates/wear/SharedConstants.java ================================================ package com.alexstyl.specialdates.wear; public final class SharedConstants { public static final String NEXT_CONTACT_EVENTS_PATH = "/next-contact-events"; public static final String KEY_DATE = "key_date"; public static final String KEY_CONTACTS_NAMES = "key_contacts_names"; private SharedConstants() { // not instantiable } } ================================================ FILE: android_common/src/main/res/values/colors.xml ================================================ #eeeeee #F6F6F6 @android:color/white #CC33B5E5 #414141 #707070 #55B2A5 #c61d30 #ffeebb #B71C1C #c61d30 #99c61d30 #aeb857 #536173 #df5948 #547bca #e5ae4f #ffffff #9e1726 #ffff4444 @color/avatar_variant_1 #ff33b5e5 #595959 #6a1b9a #99ffffff #46000000 #263238 #ff669900 #3b5998 #151515 ================================================ FILE: android_common/src/main/res/values/strings-non-translatable.xml ================================================ @string/localised_app_name Memento Calendar email Email goo.gl/GxZd6M Mathilda Dawson @string/birthday @string/birthdays @string/nameday @string/namedays @string/celebrates_one_namedays @string/todays_namedays ================================================ FILE: android_common/src/main/res/values/strings.xml ================================================ Memento Calendar Today Tomorrow Birthday Birthdays Name Days Name Day "It's %s's birthday!" "It's %s's nameday!" No name days for this day No contact has their birthday this day Settings Disable Enable %1$s %1$s and %2$s %1$s and %2$d others About Daily Reminder Service Display Name Days Name Days will be displayed for all of your contacts Name Days will not be displayed Contact the developer Found a bug? Have a suggestion? Let me know! Choose number Call Send SMS Send e-mail No application found in order to perform this action Send e-mail via Daily Reminder "Remind me for every day's events" Sound Vibrate Every day at %s Set time Set Discard Contact added Contact could not be added Contact updated Contact could not be updated @string/upcoming Silent Contacts celebrating this day There are no contacts celebrating this day Upcoming Turns %1$d Today\'s Namedays Check out %1$s - a sweet looking birthdays and namedays reminder app for Android! Get it at %2$s Share via Licences Developed and designed by Alex Styl "Special thanks to Andreas Sfakianakis for his work on the Greeklish library Kosta Stoupas for the German translation Aggela Stylianidou for the French translation Gian Maria Calzolari for the Italian translation Andrejs Kotovs for the Latvian translation" and *%s* for using and supporting the app! :) Something is wrong with the app? Got a suggestion? Send an email at %s You On Off Thank you for supporting the app Donate Loading… Search Contacts Nameday on Show more Create (No name) Include year? Save Add No birthday set No nameday for the name %s Edit Add birthday Search and Greek Italian Czech Slovak Russian Latvian (Traditional) Latvian (Extended) Hungarian Nameday Calendar Silent "Memento - Namedays" Share Support the app Open Facebook Page How would you rate the app? "It\'s pretty much horrible" "I don't like it" "It's okay" "It's good" "I LOVE it!" Rate Message %1$s turns %2$d No results found %d contacts celebrate today Today\'s Namedays "Today's nameday" "Today's namedays" %s via… Like Page Rate the app Like Facebook Page Namedays for %1$s Get %1$s at %2$s You can hide names you don\'t want to appear in the app, by long pressing on them. OK, got it Namedays will be displayed for stored contacts only Namedays will be displays for the namedays of all year Namedays for Contacts only I hope you enjoy using the app, and it is useful for you. Here are some ways you can support the app: Translate the app Translating the app Visit the following url from your computer browser Copy Link Link copied to clipboard Select the amount you would like to donate View contact No birthday set "No contacts with events found.\nTap the '+' to add some" "No contacts with special events found" Add birthday Birthday date Contact name Themes Romanian Configure Widget Done Transparency Use dark theme Contact Names Bank holiday Bank holidays Google+ community Display Bank holidays Country Bankholidays are currently supported only for Greek Clear Search for contacts Search for contacts or namedays Memento requires your permission to read your contacts in order to display when they celebrate. Fact: Some people give free cake to those who wish them Happy Birthday. Use Memento to keep track of potential cake givers. Grant permission Use all parts of names "The app will lookup namedays for all names in a contact (including surname)." "The app will lookup namedays only in contacts\' given names (excluding surname)." %1$s on %2$s Anniversary Other Custom Add event Change photo Remove photo Take new photo Pick existing photo Discard changes? No events on this date Invite friend Facebook Log In Your Friends will be here shortly :) An error came up Try again in a bit Hi, %s! Import from Facebook View Facebook Profile Friends update daily Memento on Facebook Log out Facebook Profile Nameday on %s Birthday on %s Turns %1$d on %2$s Add birthday Post on Facebook Romanian Open Source Licences Donate - Remove ads Memento on Github Import Birthdays New Aquarius Pisces Aries Taurus Gemini Cancer Leo Virgo Libra Scorpio Sagittarius Capricorn Messenger Home Mobile Work View conversation Donate %s "Hi, I'm Alex Styl" "Memento Calendar was developed in Greece for all those who don't want to forget their loved ones.\n\nIt feels great to see how well received Memento is both by users and devs! All these Play Store reviews and donations give me the strength to keep maintaining Memento with new features and polishing!"\n\nMemento is a free application, supported by ads and donations. Donating any amount, removes all ads and grants you my infinite gratitude ♥. Restore donation Placed a donation, but still see ads? Tap here Checking for donations "No donation found. Have you donated?" Select date Facebook Show Hide Welcome "Want to support Memento's development?" Tap to see more events. Advanced notification settings Change sounds, vibration, lights and more. Send wishes "Don't forget to send your wishes." "Today's bank holiday" Load my wallpaper Close Opacity Apply ================================================ FILE: android_common/src/main/res/values-cs/strings.xml ================================================ Memento Calendar Dnes Zítra Datum narození Narozeniny Svátky Svátek "%s má narozeniny" "%s má svátek" Žádný svátek pro tento den Žádný kontakt nemá narozeniny Nastavení Zakázat Povolit %1$s %1$s a %2$s %1$s a %2$d ostatní O aplikaci Služba denního připomínání Zobrazit svátky Budou zobrazeny svátky pro všechny Vaše kontakty Svátky nebudou zobrazeny Kontakt na vývojáře Nalezl/a jste chybu? Máte námět? Dejte mi vědět! Vybrat číslo Zavolat Poslat SMS Poslat e-mail Pro provedení této akce nebyla nalezena zádná aplikace Poslat emailem Denní připomínání "Připomínat každodenní události" Vyzváněcí tón Vibrace Každý den v %s Čas připomínky Nastavit Zrušit Kontakt přidán Kontakt nemohl být přidán Kontakt aktualizován Kontakt nemohl být aktualizován Tichý Kontakty slavící dnes Žádné kontakty slavící tento den Nadcházející Slaví %1$d Dnešní svátky Ozkoušejte %1$s - pěknou aplikaci pro připomínání narozenin a svátků pro Android! Získejte ji zde %2$s Sdílet přes Licence Vývoj a design Alex Styl "O překlad se zasloužili: Andreas Sfakianakis - řecký překlad Kosta Stoupas - německý překlad Aggele Stylianidou - francouzský překlad Gian Maria Calzolari - italský překlad Andrejs Kotovs - lotyšský překlad" a *%s* za používání a podporu aplikace Chyba v aplikaci? Máte nějaký podnět? Pošlete email na %s Vy Zapnout Vypnout Děkuji za podporu aplikace Darovat Nahrávání... Hledat Kontakty Svátek dne Více Vytvořit (Beze jména) Zahrnout rok? Uložit Přidat Narozeniny nenastaveny Žádné narozeniny pro %s Upravit Hledat a řecký český slovenský ruský lotyšský maďarský Kalendář svátků Tichý "Připomínač svátků" Sdílet Aplikační podpora Otevřít stránku Facebooku Jak aplikaci hodnotíte? "Je hrozná" "Nemám ji moc rád" "Je ok" "Je dobrá" "MILUJI ji!" Hodnotit Zpráva %1$s slaví %2$d Žádné výsledky nenalezeny Dnes slaví %d konkakty Dnešní svátky "Dnešní svátek" "Dnešní svátky" %s přes… Jako strana Ohodnotit aplikaci Pochválit stránku Facebooku Narozeniny pro %1$s Dostat %1$s v %2$s Dlouhým stiskem můžete skrýt jména, která nechcete v aplikaci zobrazit Ok, rozumím Svátky budou zobrazeny pouze pro uložené kontakty Svátky budou zobrazeny pro celý rok Svátky pouze pro kontakty Doufám, že se Vám aplikace líbí a je Vám užitečná. Tady jsou nějaké možnosti jak ji můžete podpořit: Přeložit aplikaci Překládání aplikace Navštívit následující odkaz ze svého počítače Zkopírovat odkaz Odkaz zkopírován do schránky Vyber částku, kterou chcete darovat Zobrazit kontakt Žádné narozeniny nastaveny "Nenalezeny žádné kontakty se zvláštními událostmi\nPoklepejte na '+' pro přidání" "Nenalezeny žádné kontakty se zvláštními událostmi" Přidat narozeniny Datum narození Jméno Motiv Konfigurace widgetu Hotovo Průhlednost Tmavý motiv Kontaktní jména Státní svátek Státní svátky Google+ komunita Zobrazit státní svátky Státní svátky pro zemi Státní svátky jsou v současnosti podporovány pouze pro Řecko Vymazat Hledat kontakty Hledat kontakty nebo svátky Memento požaduje Vaše svolení číst Vaše kontakty, aby zobrazilo datum oslavy. Fakt: Někteří lidé darují dort těm, kteří jim přejí vše nejlepší. Využijte Memento k dokumentování potencionálních dárců dortu. Povolit přístup. Svátek %s Narozeniny %s %1$s slaví %2$d Přidat narozeniny Otevřít tabuli Rumunsko ================================================ FILE: android_common/src/main/res/values-de/strings.xml ================================================ Memento Geburtstage Heute Morgen Geburtstag Geburtstage Namenstage Namenstag "%s hat Geburtstag!" "Es ist %s's Namenstag!" Keine Namenstage für heute Keiner Ihrer Konatkte hat heute Geburtstag Einstellungen Deaktivieren Aktivieren %1$s %1$s und %2$s %1$s und %2$d weitere Über Tägliche Erinnerung Namenstage anzeigen Namenstage werden für alle Ihre Kontakte angezeigt Namenstage werden nicht angezeigt Den Entwickler kontaktieren Haben Sie einen Bug gefunden? Oder Sie haben einen Vorschlag? Lassen Sie es uns wissen! Wähle Nummer Anrufen SMS schreiben Email schreiben Es wurde keine Anwendung zum Durchführen dieser Aktion gefunden Email schreiben via Tägliche Erinnerung "Tägliche Erinnerung für jedes Ereignis" Klingelton Vibration Jeden Tag um %s Zeit festlegen Fertig Lautlos Wird %1$d Nachricht Namenstag am %s Geburtstag am %s Wird %1$d am %2$s ================================================ FILE: android_common/src/main/res/values-el/strings.xml ================================================ Γιορτές Memento Σήμερα Αύριο Γενέθλια Γενέθλια Γιορτές Γιορτή "Είναι τα γενέθλια του/της %s!" "Γιορτάζει ο/η %s!" Καμία ονομαστική εορτή για αυτήν την ημέρα Καμία επαφή δεν έχει γενέθλια αυτήν την ημέρα Ρυθμίσεις Απενεργοποίηση Ενεργό %1$s %1$s και %2$s %1$s και %2$d άλλοι Σχετικά Υπηρεσία Καθημερινής Υπενθύμισης Εμφάνιση Ονομαστικών Εορτών Οι Ονομαστικές Εορτές εμφανίζονται για όλες τις επαφές Οι Ονομαστικές Εορτές δεν θα εμφανίζονται Επικοινωνία Παρουσιάστηκε κάποιο πρόβλημα; Έχετε κάποια πρόταση για την εφαρμογή; Πατήστε εδώ Επιλογή αριθμού Κλήση Αποστολή SMS Αποστολή e-mail Δεν βρέθηκε κάποια εγκατεστημένη εφαρμογή για να ολοκληρωθεί αυτή η ενέργεια Αποστολή e-mail μέσω Καθημερινή Υπενθύμιση "Θύμιζέ μου τις γιορτές και τα γενέθλια της κάθε ημέρας" Ήχος Δόνηση Κάθε μέρα στις %s Ρύθμιση Ώρας Τέλος Ακύρωση Η επαφή δημιουργήθηκε Η επαφή δεν προστέθηκε Η επαφή ανανεώθηκε Η επαφή δεν ενημερώθηκε Αθόρυβο Επαφές που γιορτάζουν αυτή την ημέρα Δεν γιορτάζει καμία επαφή αυτή την ημέρα Επερχόμενες Γίνεται %1$d Σημερινές Εορτές Δείτε το %1$s - μια μοντέρνα εφαρμογή για γενέθλια και ονομαστικές γιορτες επαφών για Android! Κατεβαστε το στο %2$s Μοιραστείτε μέσω Άδειες Υλοποιήθηκε και σχεδιάστηκε από τον Alex Styl "Πολλά ευχαριστώ στους\n \nAndreas Sfakianakis για την βιβλιοθήκη για τους Greeklish χαρακτήρες \nKosta Stoupas για την Γερμανική μετάφραση \nAggela Stylianidou για την Γαλλική μετάφραση" και *%s* που χρησιμοποιείται και υποστηρίζεται την εφαρμογή! :) Κάτι πάει στραβά με την εφαρμογή; Θέλετε κάτι νέο να προστεθεί; Στείλτε e-mail στο %s Εσάς Ενεργό Ανενεργό Σας ευχαριστώ για την υποστήριξη Δωρεά Άνοιγμα… Αναζητήστε Επαφές Γιορτάζει στις Εμφάνισε περισσότερα Δημιούργησε (Ακατανόμαστος) με έτος γέννησης; Αποθήκευση Προσθήκη Χωρίς γενέθλια Δεν υπάρχει ονομαστική εορτή για το όνομα %s Επεξεργασία Προσθήκη γενεθλίων Αναζήτηση και Ελληνικά Ιταλικά Τσέχικα Σλοβάκικα Ρώσικα Λατβικά Ουγγρικά Γλώσσα Εορτολογίου Αθόρυβο "Γιορτές - Ονομαστικές Εορτές" Μοιραστείτε Υποστηρίξτε την εφαρμογή Άνοιγα της Facebook σελίδας Πως θα βαθμολογούσατε την εφαρμογή; "Είναι απαίσια" "Δεν μου αρέσει" "Είναι εντάξει" "Πολύ καλή" "Την ΛΑΤΡΕΥΩ!" Βαθμολογείστε Μήνυμα Ο/η %1$s γίνεται %2$d Δεν βρέθηκαν αποτελέσματα %d επαφές γιορτάζουν σήμερα Σημερινές Εορτές "Η γιορτή της ημέρας" "Οι γιορτές της ημέρας" %s μέσω… Κάντε Like στην σελίδα Βαθμολογίστε την εφαρμογή Κάντε Like στην σελίδα Ονομαστικές εορτές για %1$s Κατεβάστε το %1$s στο %2$s Μπορείτε να κρύψετε ονομαστικές εορτές, πατώντας παρατεταμένα πάντως τους. ΟΚ, το \'πιασα. Οι Ονομαστικές εορτές θα εμφανίζονται μόνο για τις αποθηκευμένες επαφές Οι Ονομαστικές εορτές θα εμφανίζονται για όλες τις ημέρες του χρόνου Μόνο για επαφές Ελπίζω να σας αρέσει η εφαρμογή και να σας είναι χρήσιμη. Αν θέλετε να υποστηρίξετε την εφαρμογή, μπορείτε να επιλέξετε κάτι από τα παρακάτω Μεταφράστε την εφαρμογή Μετάφραση την εφαρμογή Επισκεφθείτε το url απο τον browser του υπολογιστή σας Αντιγραφή Το URL αντιγράφθηκε στο πρόχειρο Επιλέξτε το ποσό που θα θέλατε να προσφέρετε Προβολή επαφής Δεν έχει οριστεί ημερομηνία γενεθλίων "Δεν βρέθηκαν επαφές με εορτές. Πατήστε το '+' για προσθήκη" "Δεν βρέθηκαν επαφές με εορτές" Προσθήκη γενεθλίων Ημερομηνία γενεθλίων Όνομα επαφής Θέματα Ρωμανικά Ρύθμιση Γρ. Στοιχείου Αποθήκευση Διαφάνεια Σκοτεινό θέμα; Ονόματα επαφών Αργία Αργίες Κοινότητα Google+ Προβολή αργίων Χώρα αργίων Οι αργίες υποστηρίζονται μόνο για την Ελλάδα αυτή τη στιγμή Καθάρισμα Αναζήτηση επαφών Αναζήτηση επαφών ή εορτών Το Memento χρειάζεται την άδειά σας για να διαβάσει τις επαφές σας προκειμένου να μπορεί να σας πει πότε γιορτάζουν. Μερικοί άνθρωποι δίνουν δωρεάν γλυκό σε όποιον τους πει Χρόνια Πολλά. Χρησιμοποιήστε το Memento για να είσαστε ενήμεροι για το ποιοι γιορτάζουν. Δώστε άδεια Χρήση όλων των ονομάτων επαφών "Η εφαρμογή θα προσπαθήσει να βρει ονομαστικές εορτές από όλα τα ονόματα κάθε επαφής (συμπεριλαμβανομένου του επωνύμου)" "Η εφαρμογή θα προσπαθήσει να βρει ονομαστικές εορτές μόνο από τα μικρά ονόματα κάθε επαφές (χωρίς το επώνυμο)" %1$s στις %2$s Επέτειοι Άλλο Προσθήκη γεγονότος Αλλαγή φωτογραφίας Αφαίρεση φωτογραφίας Λήψη νέας φωτογραφίας Επιλογή υπάρχουσας φωτογραφίας Ακύρωση αλλαγών; Δεν υπάρχει γεγονότα για αυτή την ημερομηνία Πρόσκληση φίλου Σύνδεση στο Facebook Οι Φίλοι σας είναι καθοδόν :) Παρουσιάσθηκε κάποιο σφάλμα Δοκιμάστε ξανά σε λιγάκι Γεια σου, %s! Εισαγωγή από το Facebook Οι φίλοι ανανεώνονται καθημερινά Το Memento στο Facebook Αποσύνδεση Facebook προφίλ Γιορτάζει την %s Γενέθλια την %s Γίνεται %1$d την %2$s Προσθήκη γενεθλίων Άνοιγμα Τοίχου Ρωμάνικα Περισσότερες ρυθμίσεις Αλλάξτε τον ήχο, την δόνηση, το χρώμα του LED και άλλα. "Μην ξεχάσετε να τους ευχηθείτε." ================================================ FILE: android_common/src/main/res/values-fr/strings.xml ================================================ Memento Fêtes Aujourd\'hui Demain Anniversaire Anniversaires Fêtes Fête "C'est l'anniversaire de %s!" "C'est la fête de %s!" Pas de fête aujourd\'hui Pas de contact ayant son anniversaire aujourd\'hui Paramètres Désactivation Activation %1$s %1$s et %2$s %1$s et %2$d encore À propos Service de rappel quotidien Apparition des fêtes Les fêtes se montreront pour tous les contacts Les fêtes ne se montreront pas Communiquez avec le programmateur Est-ce qu\'il y a des problèmes? Est-ce que vous avez des suggestions concernant l\' application? Touchez ici Choisissez un numéro de téléphone Appel Envoi un message Envoi un courriel Pas d\' application existante afin que cette action se termine Envoi un courriel via Rappel quotidien "Rappelle-moi les fêtes et les anniversaires de chaque jour" Son Vibrer au rappel Chaque jour à %s Réglage de l\'heure Fin Silencieux Contacts qui fêtent aujourd\'hui Pas de contacts ayant sα fête ou son anniversaire aujourd\'hui Aura %1$d Voyez %1$s - Une application moderne pour les anniversaires et les fêtes des contacts pour Android!Téléchargez-la à %2$s Partagez via Licences Développée et dessinée par Alex Styl "Remerciements spéciaux\n \nAndreas Sfakianakis pour son travail à la librairie de Greeklish \nKosta Stoupas pour la traduction en allemand \nAggela Stylianidou pour la traduction en français" et *%s* qui utilisez et supportez l\'application! :) Est-se qu\' il y a des problèmes concernant l\' application? Est-ce que vous avez des suggestions concernant l\' application? Envoyez un courriel à %s Vous Merci de supporter l\'application Rechercher et Silencieux Partager Supporter l\'application Ouvrir Facebook Comment évalueriez-vous l\'application? "C\'est un peu horrible" "Je ne l'aime pas" "C'est ok" "C'est bien" "Je l'adore!" Evaluer Message Aucun résultat trouvé Sa fête est %s Son anniversaire est %s Aura %1$d ans %2$s Ajouter anniversaire ================================================ FILE: android_common/src/main/res/values-it/strings.xml ================================================ Memento Calendar Oggi Domani Compleanno Compleanni Onomastici Onomastico "E' il compleanno di %s!" "E' l'onomastico di %s!" Nessun onomastico in questo giorno Nessun compleanno in questo giorno Impostazioni Disattiva Attiva %1$s %1$s e %2$s %1$s e altri %2$d Informazioni Servizio di notifica giornaliera Visualizza onomastici Verranno visualizzati gli onomastici per tutti i tuoi contatti Gli onomastici non verranno visualizzati Contatta lo sviluppatore Hai trovato un bug? Hai un suggerimento? Fammelo sapere! Scegli numero Chiama Invia SMS Invia e-mail Nessuna applicazione trovata per questa azione Invia e-mail con Notifica giornaliera "Notificami per ogni evento del giorno" Suoneria Vibrazione Ogni giorno alle %s Imposta ora Imposta Ignora Contatto aggiunto Il contatto non può essere aggiunto Contatto aggiornato Il contatto non può essere aggiornato Silenzioso Contatti che celebrano oggi Nessun contatto celebra oggi Prossimamente Compie %1$d anni Onomastici di oggi Prova %1$s - una bella app di promemoria per compleanni e onomastici per Android! Installala da %2$s Condividi con Licenze Sviluppato e progettato da Alex Styl "Ringraziamenti speciali a Andreas Sfakianakis per il suo lavoro alla libreria di translitterazione dal Greco ("Greeklish") Kosta Stoupas per la traduzione in Tedesco Aggela Stylianidou per la traduzione in Francese Gian Maria Calzolari per la traduzione in Italiano Andrejs Kotovs per la traduzione in Lituano" e *%s* perché usi e supporti l\'app! :) C\'è qualche cosa di errato nella app? Hai un suggerimento? Invia una e-mail a %s Tu Attivo Spento Grazie per supportare l\'app Dona Sto caricando... Cerca Contatti Onomastico il Mostra altri Crea (Nessun nome) Includi l\'anno? Salva Aggiungi Compleanno non impostato Nessun onomastico per il nome %s Modifica Aggiungi compleanno Cerca e Greco Italiano Ceco Slovacco Russo Lituano Ungherese Calendario onomastico Silenzioso "Memento - Onomastici" Condividi Supporta l\'app Vai alla pagina Facebook Quanto valuteresti l\'app? "Pessima" "Non mi piace" "Va bene" "Mi è piaciuta" "L'adoro!" Valutazione Messaggio %1$s compie %2$d anni Nessun risultato trovato %d contatti celebrano oggi Onomastici di oggi "Onomastico di oggi" "Onomastici di oggi" %s con… Mi piace Valuta l\'app Mi piace su Facebook Onomastici del %1$s Installa %1$s da %2$s Puoi nascondere I nomi che non vuoi visualizzare nella app premendoli a lungo. OK, ho capito Verranno visualizzati gli onomastici solo per i contatti memorizzati Verranno visualizzati tutti gli onomastici dell\'anno Onomastici solo per i contatti Mi auguro che tu sia soddisfatto della app e che ti sia utile. Ecco alcuni modi per supportarmi: Tradurre l\'applicazione Per tradurre l\'applicazione Visita l\'url seguente dal browser del tuo computer Copia il link Link copiato negli appunti Scegli l\'importo che vuoi donare Visualizza il contatto Compleanno non impostato "Non ho trovato contatti con eventi speciali.\nTocca il '+' per aggiungerne" "Non ho trovato contatti con eventi speciali" Aggiungi compleanno Compleanno Nome contatto Temi Romeno Configura il widget Fatto Trasparenza Tema scuro? Nomi dei contatti Festività Festività Comunità Google+ Visualizza Festività Nazione Al momento sono supportate solo le Festività greche Pulisci Ricerca dei contatti Ricerca dei contatti o onomastici L\'app necessita il tuo permesso di leggere i contatti per poter visualizzare le date. Alcune persone offrono una torta a chi gli augura Buon Compleanno. Usa questa app per tenere traccia dei potenziali donatori di torta. Concedi il permesso Usa sia il nome che il cognome "L'app cercherà gli onomastici per il nome completo dei contatti (cognome incluso)" "L\'app cercherà gli onomastici per il solo nome dei contatti (cognome escluso)" %1$s il %2$s Anniversario Altro Personalizzato Aggiungi evento Cambia la foto Rimuovi la foto Scatta una nuova foto Scegli una foto esistente Annulli le modifiche? Nessun evento in questa data Invita un amico Accedi a Facebook I tuoi Amici saranno qui a breve :) Si è verificato un errore Riprova tra poco Ciao, %s! Importa da Facebook Aggiornamento Amici giornalmente Memento su Facebook Esci Profilo Facebook Onomastico il %s Compleanno il %s Compie %1$d anni il %2$s Aggiungi compleanno Apri il diario Rumeno ================================================ FILE: android_common/src/main/res/values-lv/bools.xml ================================================ true ================================================ FILE: android_common/src/main/res/values-lv-rLV/strings.xml ================================================ Memento Calendar Šodiena Rītdien Dzimšanas diena Dzimšanas dienas Vārda dienas Vārda diena "%s dzimšanas diena!" "%s vārda diena!" Šajā dienā nevienam nav vārda dienas Šajā dienā nevienai kontaktpersonai nav dzimšanas dienas Iestatījumi Izslēgt Ieslēgt %1$s %1$s un %2$s %1$s un %2$d citi Par Ikdienas atgādinājumi Rādīt vārda dienas Tiks rādītas vārda dienas atbilstoši uzstādījumiem zemāk Vārda dienas netiks rādītas Sazināties ar izstrādātāju Atradi kļūdu? Ir ieteikumi? Padod man ziņu! Izvēlēties numurs Zvanīt Sūtīt SMS Sūtīt e-pastu Nav nevienas piemērotas lietotnes, lai paveiktu darbību Sūtīt e-pastu ar Ikdienas atgādinājums "Atgādināt par katras dienas notikumiem" Skaņas tonis Vibrācija Katru dienu plkst. %s Uzstādīt laiku Uzstādīt Atmest Kontaktpersona pievienota Kontaktpersona netika pievienota Kontaktpersona atjaunota Kontaktpersona netika atjaunota Klusums Kontaktpersonas, kas svin šajā dienā Kontaktos nav neviena, kas svin šajā dienā Svētku dienas drīzumā Paliek %1$d Šodienas vārda dienas Izmēģini %1$s - glīta dzimšanas dienu un vārda dienu atgādinājumu Android lietotne! Iegūsti to %2$s Dalies ar Licences Alex Styl izstrāde un dizains "Īpaša pateicība\n \nAndreas Sfakianakis par darbu ar grieķu resursiem \nKosta Stoupas par vācu valodas tulkojumu \nAggela Stylianidou par franču valodas tulkojumu \nGian Maria Calzolari par itāļu valodas tulkojumu \nAndrejs Kotovs par latviešu valodas tulkojumu" un *%s* par lietošanu un atbalstu! :) Kaut kas ir nepareizi lietotnē? Ir ieteikumi? Sūti e-pastu uz %s tev Iesl. Izsl. Pateicos par atbalstu lietotnei Ziedot Ielāde... Meklēt Kontaktpersonas Vārda diena Rādīt vairāk Izveidot (Nav vārda) Iekļaut gadskaitli? Saglabāt Pievienot Dzimšanas diena nav norādīta %s vārda diena nav zināma Rediģēt Pievienot dzimšanas dienu Meklēt un Grieķu Itāļu Čehu Slovāku Krievu Latviešu Ungāru Vārda dienu kalendārs Klusums "Memento - Vārda dienas" Dalīties Atbalsti lietotni Atvērt Facebook Vēlies novērtēt lietotni? "Diezgan briesmīgi" "Man nepatīk" "Ir OK" "Labi" "Man ļoti patīk!" Novērtēt Īsziņa %1$s paliek %2$d Nekas nav atrasts %d kontaktpersonas šodien svin Šodienas vārda dienas "Šodienas vārda diena" "Šodienas vārda dienas" %s ar… Like Page Novērtē lietotni Like Facebook Page Vārda dienas %1$s Iegūsti %1$s no %2$s Ilgāk paturot, vari paslēpt vārdus, kurus nevēlies redzēt lietotnē Labi, sapratu Tiks rādītas tikai kontaktpersonu vārda dienas Tiks rādītas vārda dienas visām gada dienām Vārda dienas tikai kontaktpersonām Es ceru, ka tev patīk šī lietotne. Tu vari atbalstīt lietotnes izstrādi šādos veidos: Tulkot lietotni Lietotnes tulkošana Atvērt šo saiti no datora pārlūkprogrammas Kopēt saiti Saite ir nokopēta Izvēlies ziedojuma summu Apskatīt kontaktpersonu Dzimšanas datums nav uzstādīts "Kontaktpersonas ar svētku dienām nav atrastas.\n\nSpied '+', lai pievienotu" "Kontaktpersonas ar svētku dienām nav atrastas" Pievienot dzimšanas dienu Dzimšanas datums Kontaktpersonas vārds Tēmas Rumāņu Konfigurēt logrīku Darīts Caurspīdīgums Tumšā tēma? Kontaktpersonu vārdi Valsts svētku diena Valsts svētku dienas Google+ kopiena Rādīt valsts svētku dienas Valsts Pašlaik tiek atbalstītas tikai Grieķijas valsts svētku dienas Dzēst Meklēt kontaktpersonas Meklēt kontaktpersonas vai vārda dienas Memento lietotnei nepieciešama piekļuve taviem kontaktiem, lai parādītu kontaktpersonu svētku dienas. Fakts: Daudzi cilvēki mēdz cienāt ar kūkām sveicējus dzimšanas dienā. Izmanto Memento, lai laicīgi uzzinātu par potenciālajiem cienastiem. Atļaut piekļuvi Izmantot visas vārdu daļas "Vārda dienu piemeklēšanai lietotne izmantos visas kontaktpersonu vārda daļas, t.sk. uzvārdus" "Lietotne neiekļaus kontaktpersonu uzvārdus meklējot vārda dienas" %1$s - %2$s Jubileja Citi Pielāgoti Pievienot notikumu Mainīt fotoattēlu Noņemt fotoattēlu Uzņemt jaunu fotoattēlu Atlasīt jaunu fotoattēlu Atcelt izmaiņas? Nav pasākumu šajā datumā Aicināt draugus Ieiet Facebook Tavi draugi drīz būs te :) Radusies kļūda! Pēc brīža mēģini atkārtoti! Sveiki, %s! Importēt no Facebook Draugu atjaunājumi ik dienu Memento lietotne Facebook Iziet Facebook profils Vārda diena %s Dzimšanas diena %s Paliek %1$d vecs %2$s Pievienot dzimšanas dienu Apskatīt sienu Rumāņu ================================================ FILE: android_common/src/main/res/values-nl/strings.xml ================================================ Memento Calendar Vandaag Morgen Verjaardag Verjaardagen Naamdagen Naamdag "Het is de verjaardag van %s!" "Het is de naamdag van %s!" Geen naamdagen op deze dag Niemand van je contacten heeft vandaag zijn verjaardag. Instellingen Uitschakelen Inschakelen %1$s %1$s en %2$s %1$s en %2$d anderen Over Service voor dagelijkse herinneringen Toon naamdagen Naamdagen zullen worden getoond voor al je contactpersonen Naamdagen zullen niet getoond worden Contacteer de ontwikkelaar Een bug gevonden? Een suggestie? Laat het me weten! Kies nummer Bel Stuur SMS Stuur e-mail Geen app gevonden om deze actie uit te voeren Stuur e-mail via Dagelijkse herinnering "Toon me dagelijks een herinnering voor de verjaardagen of naamdagen van de dag" Beltoon Trillen Every day om %s Tijd van herinnering opslaan Annuleer Contact toegevoegd Contact kon niet toegevoegd worden Contact bijgewerkt Contact kon niet bijgewerkt worden Stil Contactpersonen die deze dag vieren Er zijn geen contactpersonen die deze dag vieren Eerstvolgende Wordt %1$d Naamdagen van vandaag Je moet %1$s eens bekijken - een knappe app voor Android die herinneringen toont voor verjaardagen en naamdagen! Download van %2$s Delen via Licenties Ontwikkeld en ontworpen door Alex Styl "Speciaal bedankt aan Andreas Sfakianakis voor zijn werk aan de Greeklish bibliotheek Kosta Stoupas voor de Duitse vertaling Aggela Stylianidou voor de Franse vertaling Gian Maria Calzolari voor de Italiaanse vertaling Andrejs Kotovs voor de Letse vertaling Bert Van Dooren voor de Nederlandse vertaling" en *%s* om deze app te gebruiken en te ondersteunen! :) Is er iets mis met de app? Heb je een suggestie? Stuur een e-mail naar %s JIJ Aan Uit Bedankt om deze app te ondersteunen Doneer Bezig met laden... Zoeken Contactpersonen Naamdag op Toon meer Aanmaken (Geen naam) Jaar toevoegen? Opslaan Toevoegen Geen verjaardag gezet Geen naamdag voor de naam %s Bewerken Verjaardag toevoegen Zoeken en Grieks Italiaans Tsjechisch Slovaaks Russisch Lets Hongaars Naamdag kalender Stil "Memento - Naamdagen" Delen Ondersteun de app Open de Facebook Pagina Welke waardering zou je de app geven? "Het is echt afgrijselijk" "Ik hou er niet van" "'t Is wel ok" "Het is goed" "Ik kan niet meer zonder!" Waardering geven Bericht %1$s wordt %2$d Geen resultaten gevonden %d contactpersonen vieren vandaag Naamdagen van vandaag "Naamdag van vandaag" "Naamdagen van vandaag" %s via... Like de pagina Geef de app een waardering Like de Facebook pagina Naamdagen voor %1$s Download %1$s op %2$s Je kan de namen verbergen die je niet wil zien in de app door er lang op te duwen. Ok, verstaan. Enkel de naamdagen van je contactpersonen zullen worden getoond Alle naamdagen van het jaar zullen getoond worden Enkel naamdagen voor contactpersonen Ik hoop dat je de app leuk en bruikbaar vindt. Hier zijn enkele manieren om de app de ondersteunen: Vertaal de app De app vertalen Open de volgende link op je computer Kopiëer de link Link gekopiëerd naar het klembord Selecteer hoeveel je wil doneren Toon contactpersoon Geen verjaardag gevonden "Geen contactpersonen met speciale gebeurtenissen gevonden. \nDuw op de '+' om er toe te voegen" "Geen contacten met speciale gebeurtenissen gevonden" Verjaardag toevoegen Geboortedatum Naam contactpersoon Thema\'s Roemeens Stel widget in Klaar Transparantie Donker thema? Namen contactpersonen Verlofdag Verlofdagen Google+ gemeenschap Toon verlofdagen Land Momenteel zijn enkel Griekse verlofdagen ondersteund Leegmaken Contacten zoeken Contacten of naamdagen zoeken Memento heeft je goedkeuring nodig om je contacten in te lezen en zo te kunnen tonen wanneer ze gevierd worden. "Feit: sommige mensen geven een gratis taart aan zij die hen Gelukkige Verjaardag wensen. Gebruik Memento om zulke potentiële taartgevers in het oog te houden. " Toestemming verlenen Gebruik alle delen van namen "De app zal de naamdagen opzoeken voor alle namen in een contact (inclusief achternaam)" "De app zal naamdagen enkel op basis van de voornaam van de contacten opzoeken (exclusief achternaam)" %1$s op %2$s Jubileum Andere Aangepast Gebeurtenis toevoegen Foto wijzigen Foto verwijderen Nieuwe foto nemen Kies bestaande foto Wijzigingen verwerpen? Geen gebeurtenissen op deze datum Vriend uitnodigen Aanmelden met Facebook Je vrienden zullen hier weldra zijn :-) Er is iets fout gegaan Wacht even en probeer nog eens Hey, %s! Importeren van Facebook Vrienden worden dagelijks geüpdatet Memento op Facebook Afmelden Facebook profiel Naamdag op %s Verjaardag op %s Wordt %1$d op %2$s Verjaardag toevoegen Open Prikbord Roemeens ================================================ FILE: android_common/src/main/res/values-sk/bool.xml ================================================ true ================================================ FILE: android_mobile/.gitignore ================================================ /build ================================================ FILE: android_mobile/build.gradle ================================================ plugins { id "io.gitlab.arturbosch.detekt" version "1.0.0.M13.2" } apply from: '../versions.gradle' apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'io.fabric' detekt { version = "1.0.0.M13.2" profile("main") { input = "$projectDir/src/main/java" config = "$rootDir/team-props/static-analysis/detekt-config.yml" filters = ".*test.*,.*/resources/.*,.*/tmp/.*" output = "$projectDir/build/reports/detekt.xml" } } project.afterEvaluate { check.dependsOn tasks['detektCheck'] } android { compileSdkVersion androidCompileSdkVersion buildToolsVersion androidBuildToolsVersion applicationVariants.all { variant -> variant.outputs.all { output -> output.outputFileName = new File( "./../../../../../build/", output.outputFileName.replace(".apk", "-${variant.versionName}.apk")) } } defaultConfig { applicationId 'com.alexstyl.specialdates' minSdkVersion 16 targetSdkVersion 27 versionCode androidVersionCode versionName androidVersionName resValue "string", "admob_unit_id", "\"$adMobUnitId\"" buildConfigField "String", "FILE_PROVIDER", "\"com.alexstyl.specialdates.fileprovider\"" manifestPlaceholders = [crashlyticsApiKey: crashlyticsKey, fileProvider : "com.alexstyl.specialdates.fileprovider"] multiDexEnabled true } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } debug { minifyEnabled false } } packagingOptions { exclude 'META-INF/LICENSE.txt' exclude 'META-INF/NOTICE.txt' } lintOptions { abortOnError false } testOptions { unitTests.returnDefaultValues = true } } dependencies { implementation fileTree(dir: 'libs', exclude: 'android-support-v4.jar', include: ['*.jar']) implementation project(':android_common') implementation "com.android.support:design:$android_support_version" implementation "com.android.support:cardview-v7:$android_support_version" implementation "com.android.support:appcompat-v7:$android_support_version" implementation "com.android.support:recyclerview-v7:$android_support_version" implementation "com.android.support:transition:$android_support_version" implementation 'com.android.support.constraint:constraint-layout:1.0.2' implementation 'com.android.support:multidex:1.0.3' implementation 'com.google.dagger:dagger:2.9' kapt 'com.google.dagger:dagger-compiler:2.9' compileOnly 'javax.annotation:jsr250-api:1.0' implementation 'com.novoda:notils:2.2.15' implementation 'com.nostra13.universalimageloader:universal-image-loader:1.9.5' implementation 'com.novoda:simple-chrome-custom-tabs:0.1.6' implementation('com.crashlytics.sdk.android:crashlytics:2.6.7@aar') { transitive = true } implementation "com.google.android.gms:play-services-wearable:$play_services_version" implementation 'com.theartofdev.edmodo:android-image-cropper:2.3.1' implementation "com.google.firebase:firebase-core:$play_services_version" implementation "com.google.firebase:firebase-ads:$play_services_version" implementation 'io.reactivex.rxjava2:rxandroid:2.0.1' implementation 'io.reactivex.rxjava2:rxjava:2.1.0' implementation 'com.evernote:android-job:1.2.4' debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.5' debugImplementation 'com.facebook.stetho:stetho:1.3.0' testImplementation 'junit:junit:4.12' testImplementation 'org.easytesting:fest-assert-core:2.0M10' testImplementation 'org.mockito:mockito-core:1.10.19' testImplementation 'joda-time:joda-time:2.9.4' testImplementation 'org.json:json:20140107' androidTestImplementation 'junit:junit:4.12' } apply from: "$rootDir/android_mobile/google_services.gradle" apply plugin: 'com.google.gms.google-services' ================================================ FILE: android_mobile/google_services.gradle ================================================ task copyGoogleServicesData() { doLast { if (!file("$rootDir/android_mobile/google-services.json").exists()) { copy { from "$rootDir/.circleci/ci-scripts/mock-google-services.json" into "$rootDir/android_mobile" rename { String fileName -> fileName.replace("mock-google-services.json", "google-services.json") } } } } } tasks.whenTaskAdded { task -> if (task.name == 'processDebugGoogleServices' || task.name == 'processReleaseGoogleServices') { task.dependsOn copyGoogleServicesData } } ================================================ FILE: android_mobile/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # By default, the flags in this file are appended to flags specified # in /Users/alexstyl/android/sdk/tools/proguard/proguard-android.txt # You can edit the include path and order by changing the proguardFiles # directive in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Joda Time -dontwarn org.joda.convert.** -dontwarn org.joda.time.** -keep class org.joda.time.** { *; } -keep interface org.joda.time.** { *; } -keep class com.crashlytics.** { *; } -dontwarn com.crashlytics.** -keepattributes SourceFile,LineNumberTable,Annotation -keep class com.crashlytics.android.** -keep class com.android.vending.billing.** -keep class com.tozny.crypto.android.AesCbcWithIntegrity$PrngFixes$* { *; } ================================================ FILE: android_mobile/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: android_mobile/src/debug/assets/mock/facebook-calendar.ics ================================================ BEGIN:VCALENDAR PRODID:-//Facebook//NONSGML Facebook Events V1.0//EN X-WR-CALNAME:Friends' Birthdays X-PUBLISHED-TTL:PT12H X-ORIGINAL-URL:https://www.facebook.com/events/birthdays/ VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH BEGIN:VEVENT DTSTART:20171219 SUMMARY:Alexandros Stylianidis's birthday RRULE:FREQ=YEARLY DURATION:P1D UID:b1358181263@facebook.com END:VEVENT BEGIN:VEVENT DTSTART:20171219 SUMMARY:Alex Alex's birthday RRULE:FREQ=YEARLY DURATION:P1D UID:b100010984206219@facebook.com END:VEVENT END:VCALENDAR ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/DebugAppComponent.java ================================================ package com.alexstyl.specialdates; import com.alexstyl.resources.ResourcesModule; import com.alexstyl.specialdates.contact.ContactsModule; import com.alexstyl.specialdates.dailyreminder.DailyReminderModule; import com.alexstyl.specialdates.date.DateModule; import com.alexstyl.specialdates.debug.DebugFragment; import com.alexstyl.specialdates.debug.DebugModule; import com.alexstyl.specialdates.donate.DonateModule; import com.alexstyl.specialdates.events.namedays.NamedayModule; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsModule; import com.alexstyl.specialdates.images.ImageModule; import com.alexstyl.specialdates.person.PersonModule; import com.alexstyl.specialdates.upcoming.UpcomingEventsModule; import javax.inject.Singleton; import dagger.Component; @Singleton @Component(modules = { AndroidApplicationModule.class, NamedayModule.class, PersonModule.class, ContactsModule.class, PeopleEventsModule.class, DonateModule.class, DebugModule.class, UpcomingEventsModule.class, DailyReminderModule.class, ResourcesModule.class, ImageModule.class, DateModule.class}) public interface DebugAppComponent { void inject(DebugFragment fragment); } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/DebugApplication.java ================================================ package com.alexstyl.specialdates; import com.alexstyl.resources.ResourcesModule; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsModule; import com.alexstyl.specialdates.images.ImageModule; public class DebugApplication extends MementoApplication { private DebugAppComponent debugAppComponent; @Override public void onCreate() { super.onCreate(); debugAppComponent = DaggerDebugAppComponent.builder() .androidApplicationModule(new AndroidApplicationModule(this)) .peopleEventsModule(new PeopleEventsModule(this)) .imageModule(new ImageModule(getResources())) .resourcesModule(new ResourcesModule(this, getResources())) .build(); } @Override protected void initialiseDependencies() { super.initialiseDependencies(); new OptionalDependencies(this).initialise(); } public DebugAppComponent getDebugAppComponent() { return debugAppComponent; } } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/OptionalDependencies.java ================================================ package com.alexstyl.specialdates; import android.app.Application; import android.content.Context; import com.facebook.stetho.Stetho; import com.squareup.leakcanary.LeakCanary; class OptionalDependencies { private final Context context; OptionalDependencies(Context context) { this.context = context.getApplicationContext(); } void initialise() { Stetho.initializeWithDefaults(context); if (!LeakCanary.isInAnalyzerProcess(context)) { LeakCanary.install((Application) context); } } } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/debug/DebugActivity.java ================================================ package com.alexstyl.specialdates.debug; import android.os.Bundle; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.ui.base.ThemedMementoActivity; public class DebugActivity extends ThemedMementoActivity { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_debug); } } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/debug/DebugFragment.kt ================================================ package com.alexstyl.specialdates.debug import android.app.Activity import android.app.DatePickerDialog import android.content.ContentUris import android.content.Intent import android.net.Uri import android.os.Bundle import android.preference.Preference import android.provider.CalendarContract import android.provider.ContactsContract import android.widget.Toast import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.DebugApplication import com.alexstyl.specialdates.Optional import com.alexstyl.specialdates.R import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.contact.ContactSource.SOURCE_DEVICE import com.alexstyl.specialdates.contact.ContactsProvider import com.alexstyl.specialdates.contact.DisplayName import com.alexstyl.specialdates.dailyreminder.* import com.alexstyl.specialdates.date.ContactEvent import com.alexstyl.specialdates.date.Date import com.alexstyl.specialdates.date.DateParser import com.alexstyl.specialdates.donate.DebugDonationPreferences import com.alexstyl.specialdates.donate.DonateMonitor import com.alexstyl.specialdates.events.bankholidays.BankHoliday import com.alexstyl.specialdates.events.namedays.NamedayUserSettings import com.alexstyl.specialdates.events.namedays.NamesInADate import com.alexstyl.specialdates.events.peopleevents.DebugPeopleEventsUpdater import com.alexstyl.specialdates.events.peopleevents.StandardEventType import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsSettings import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsViewRefresher import com.alexstyl.specialdates.facebook.friendimport.FacebookFriendsIntentService import com.alexstyl.specialdates.facebook.login.FacebookLogInActivity import com.alexstyl.specialdates.support.AskForSupport import com.alexstyl.specialdates.ui.base.MementoPreferenceFragment import com.alexstyl.specialdates.upcoming.widget.today.UpcomingWidgetConfigureActivity import com.alexstyl.specialdates.wear.WearSyncUpcomingEventsView import com.evernote.android.job.JobRequest import java.net.URI import java.util.Calendar import javax.inject.Inject class DebugFragment : MementoPreferenceFragment() { var dailyReminderDebugPreferences: DailyReminderDebugPreferences? = null @Inject set var namedayUserSettings: NamedayUserSettings? = null @Inject set var contactsProvider: ContactsProvider? = null @Inject set var refresher: UpcomingEventsViewRefresher? = null @Inject set var tracker: CrashAndErrorTracker? = null @Inject set var monitor: DonateMonitor? = null @Inject set var upcomingEventsSettings: UpcomingEventsSettings? = null @Inject set var dateParser: DateParser? = null @Inject set var notifier: DailyReminderNotifier? = null @Inject set var peopleEventsUpdater: DebugPeopleEventsUpdater? = null @Inject set var dailyReminderViewModelFactory: DailyReminderViewModelFactory? = null @Inject set var askForSupport: AskForSupport? = null @Inject set private val onDailyReminderDateSelectedListener = DatePickerDialog.OnDateSetListener { _, year, month, dayOfMonth -> val month1 = month + 1 // dialog picker months have 0 index dailyReminderDebugPreferences!!.setSelectedDate(dayOfMonth, month1, year) } override fun onCreate(paramBundle: Bundle?) { super.onCreate(paramBundle) val debugAppComponent = (activity!!.application as DebugApplication).debugAppComponent debugAppComponent.inject(this) addPreferencesFromResource(R.xml.preference_debug) dailyReminderDebugPreferences = DailyReminderDebugPreferences.newInstance(activity) findPreference(R.string.key_debug_refresh_db)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { peopleEventsUpdater?.refresh() showToast("Refreshing Database") true } findPreference(R.string.key_debug_refresh_widget)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { refresher!!.refreshViews() showToast("Widget(s) refreshed") true } findPreference(R.string.key_debug_daily_reminder_date_enable)!!.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> dailyReminderDebugPreferences!!.setEnabled(newValue as Boolean) true } findPreference(R.string.key_debug_daily_reminder_date)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { val today = dailyReminderDebugPreferences!!.selectedDate val datePickerDialog = DatePickerDialog( activity!!, onDailyReminderDateSelectedListener, today.year, today.month - 1, today.dayOfMonth ) datePickerDialog.show() false } findPreference(R.string.key_debug_daily_reminder)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { JobRequest.Builder(DailyReminderJob.TAG) .startNow() .build() .schedule() showToast("Daily Reminder Triggered") true } findPreference(R.string.key_debug_start_calendar)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { startDateIntent() true } findPreference(R.string.key_debug_trigger_wear_service)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { WearSyncUpcomingEventsView(activity).reloadUpcomingEventsView() true } findPreference(R.string.key_debug_reset_donations)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { preference -> DebugDonationPreferences.newInstance(preference.context, monitor).reset() Toast.makeText(preference.context, "Donations reset. You should see ads from now on", Toast.LENGTH_SHORT).show() true } findPreference(R.string.key_debug_trigger_support)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { preference -> DebugPreferences.newInstance(preference.context, R.string.pref_call_to_rate).wipe() askForSupport!!.requestForRatingSooner() val message = "Support triggered. You should now see a prompt to rate the app when you launch it" showToast(message) true } findPreference(R.string.key_debug_facebook)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { val intent = Intent(activity, FacebookLogInActivity::class.java) startActivity(intent) true } findPreference(R.string.key_debug_facebook_fetch_friends)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { val intent = Intent(activity, FacebookFriendsIntentService::class.java) activity!!.startService(intent) true } findPreference(R.string.key_debug_open_contact)!!.onPreferenceClickListener = Preference.OnPreferenceClickListener { val contactPickerIntent = Intent( Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI ) startActivityForResult(contactPickerIntent, RESULT_PICK_CONTACT) true } findPreference(R.string.key_debug_trigger_daily_reminder_notification_one)!! .onPreferenceClickListener = Preference.OnPreferenceClickListener { notifyForContacts(arrayListOf( contactEventOn(Date.today().minusDay(365 * 10), Contact(123L, "Peter".toDisplayName(), URI.create("content://com.android.contacts/contacts/123"), SOURCE_DEVICE), StandardEventType.BIRTHDAY) )) true } findPreference(R.string.key_debug_trigger_daily_reminder_notification_many)!! .onPreferenceClickListener = Preference.OnPreferenceClickListener { notifyForContacts(arrayListOf( contactEventOn(Date.today().minusDay(365 * 10), Contact(336L, "Peter".toDisplayName(), URI.create("content://com.android.contacts/contacts/336"), SOURCE_DEVICE), StandardEventType.NAMEDAY), contactEventOn(Date.today().minusDay(365 * 10), Contact(123L, "Alex".toDisplayName(), URI.create("content://com.android.contacts/contacts/123"), SOURCE_DEVICE), StandardEventType.BIRTHDAY), contactEventOn(Date.today().minusDay(365 * 10), Contact(108L, "Anna".toDisplayName(), URI.create("content://com.android.contacts/contacts/108"), SOURCE_DEVICE), StandardEventType.ANNIVERSARY), contactEventOn(Date.today().minusDay(365 * 10), Contact(108L, "Anna".toDisplayName(), URI.create("content://com.android.contacts/contacts/108"), SOURCE_DEVICE), StandardEventType.OTHER) )) true } findPreference(R.string.key_debug_trigger_namedays_notification)!! .onPreferenceClickListener = Preference.OnPreferenceClickListener { notifier!!.notifyFor( DailyReminderViewModel( dailyReminderViewModelFactory!!.summaryOf(emptyList()), emptyList(), namedaysNotifications( arrayListOf("NamedayTest", "Alex", "Bravo", "NamedaysRock" , "Alex", "Bravo", "NamedaysRock" , "Alex", "Bravo", "NamedaysRock" , "Alex", "Bravo", "NamedaysRock")), Optional.absent() ) ) true } findPreference(R.string.key_debug_trigger_bank_holiday)!! .onPreferenceClickListener = Preference.OnPreferenceClickListener { notifier!!.notifyFor( DailyReminderViewModel( dailyReminderViewModelFactory!!.summaryOf(emptyList()), emptyList(), Optional.absent(), bankholidayNotification() ) ) true } findPreference(R.string.key_debug_configure_widgets)!! .onPreferenceClickListener = Preference.OnPreferenceClickListener { val intent = Intent(activity, UpcomingWidgetConfigureActivity::class.java) activity!!.startActivity(intent) true } } private fun notifyForContacts(contacts: ArrayList) { val viewModels = contacts .toViewModels() notifier!!.notifyFor( DailyReminderViewModel( dailyReminderViewModelFactory!!.summaryOf(viewModels), viewModels, Optional.absent(), Optional.absent() ) ) } private fun bankholidayNotification() = Optional(dailyReminderViewModelFactory!!.viewModelFor(BankHoliday("Test Bank Holiday", Date.today()))) private fun namedaysNotifications(arrayList: ArrayList): Optional = Optional(dailyReminderViewModelFactory!!.viewModelFor(NamesInADate(Date.today(), arrayList ))) private fun contactEventOn(date: Date, contact: Contact, standardEventType: StandardEventType) = ContactEvent(Optional.absent(), standardEventType, date, contact) private fun showToast(message: String) { Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() } private fun startDateIntent() { val cal = Calendar.getInstance() val builder = CalendarContract.CONTENT_URI.buildUpon() builder.appendPath("time") ContentUris.appendId(builder, cal.timeInMillis) val intent = Intent(Intent.ACTION_VIEW) .setData(builder.build()) startActivity(intent) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == RESULT_PICK_CONTACT && resultCode == Activity.RESULT_OK) { val intent = Intent(Intent.ACTION_VIEW) val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, data.data!!.lastPathSegment.toString()) intent.data = uri startActivity(intent) } } companion object { private const val RESULT_PICK_CONTACT = 4929 } private fun String.toDisplayName(): DisplayName = DisplayName.from(this) private fun ArrayList.toViewModels(): ArrayList { val viewmodels = arrayListOf() forEach { viewmodels.add(dailyReminderViewModelFactory!!.viewModelFor(it.contact, listOf(it))) } return viewmodels } } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/debug/DebugModule.java ================================================ package com.alexstyl.specialdates.debug; import android.content.Context; import com.alexstyl.specialdates.dailyreminder.DailyReminderDebugPreferences; import com.alexstyl.specialdates.events.namedays.NamedayDatabaseRefresher; import com.alexstyl.specialdates.events.peopleevents.DebugPeopleEventsUpdater; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsStaticEventsRefresher; import dagger.Module; import dagger.Provides; @Module public class DebugModule { @Provides DebugPeopleEventsUpdater debugPeopleEventsUpdater(PeopleEventsStaticEventsRefresher peopleEventsStaticEventsRefresher, NamedayDatabaseRefresher namedayDatabaseRefresher) { return new DebugPeopleEventsUpdater(peopleEventsStaticEventsRefresher, namedayDatabaseRefresher); } @Provides DailyReminderDebugPreferences debugPreferences(Context context) { return DailyReminderDebugPreferences.newInstance(context); } } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/debug/DebugPreferences.java ================================================ package com.alexstyl.specialdates.debug; import android.content.Context; import android.support.annotation.StringRes; import com.alexstyl.specialdates.EasyPreferences; final class DebugPreferences { private final EasyPreferences preferences; public static DebugPreferences newInstance(Context context, @StringRes int prefKey) { return new DebugPreferences(EasyPreferences.createForPrivatePreferences(context, prefKey)); } private DebugPreferences(EasyPreferences preferences) { this.preferences = preferences; } void wipe() { preferences.clear(); } } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/donate/DebugDonationPreferences.java ================================================ package com.alexstyl.specialdates.donate; import android.content.Context; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.R; public final class DebugDonationPreferences { private final EasyPreferences easyPreferences; private final DonateMonitor donateMonitor; public static DebugDonationPreferences newInstance(Context context, DonateMonitor donateMonitor) { return new DebugDonationPreferences(EasyPreferences.createForDefaultPreferences(context), donateMonitor); } private DebugDonationPreferences(EasyPreferences easyPreferences, DonateMonitor donateMonitor) { this.easyPreferences = easyPreferences; this.donateMonitor = donateMonitor; } public void reset() { easyPreferences.setBoolean(R.string.key_has_donated, false); donateMonitor.onDonationUpdated(); } } ================================================ FILE: android_mobile/src/debug/java/com/alexstyl/specialdates/events/peopleevents/DebugPeopleEventsUpdater.java ================================================ package com.alexstyl.specialdates.events.peopleevents; import com.alexstyl.specialdates.events.namedays.NamedayDatabaseRefresher; public final class DebugPeopleEventsUpdater { private final PeopleEventsStaticEventsRefresher peopleEventsStaticEventsRefresher; private final NamedayDatabaseRefresher namedayDatabaseRefresher; public DebugPeopleEventsUpdater(PeopleEventsStaticEventsRefresher peopleEventsStaticEventsRefresher, NamedayDatabaseRefresher namedayDatabaseRefresher) { this.peopleEventsStaticEventsRefresher = peopleEventsStaticEventsRefresher; this.namedayDatabaseRefresher = namedayDatabaseRefresher; } public void refresh() { peopleEventsStaticEventsRefresher.rebuildEvents(); namedayDatabaseRefresher.refreshNamedaysIfEnabled(); } } ================================================ FILE: android_mobile/src/debug/res/layout/activity_debug.xml ================================================ ================================================ FILE: android_mobile/src/debug/res/layout/debug_activity_animations.xml ================================================ ================================================ FILE: android_mobile/src/debug/res/layout/debug_activity_mixing_colors.xml ================================================ ================================================ FILE: android_mobile/src/main/AndroidManifest.xml ================================================ ================================================ FILE: android_mobile/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl ================================================ /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.vending.billing; import android.os.Bundle; /** * InAppBillingService is the service that provides in-app billing version 3 and beyond. * This service provides the following features: * 1. Provides a new API to get details of in-app items published for the app including * price, type, title and description. * 2. The purchase flow is synchronous and purchase information is available immediately * after it completes. * 3. Purchase information of in-app purchases is maintained within the Google Play system * till the purchase is consumed. * 4. An API to consume a purchase of an inapp item. All purchases of one-time * in-app items are consumable and thereafter can be purchased again. * 5. An API to get current purchases of the user immediately. This will not contain any * consumed purchases. * * All calls will give a response code with the following possible values * RESULT_OK = 0 - success * RESULT_USER_CANCELED = 1 - User pressed back or canceled a dialog * RESULT_SERVICE_UNAVAILABLE = 2 - The network connection is down * RESULT_BILLING_UNAVAILABLE = 3 - This billing API version is not supported for the type requested * RESULT_ITEM_UNAVAILABLE = 4 - Requested SKU is not available for purchase * RESULT_DEVELOPER_ERROR = 5 - Invalid arguments provided to the API * RESULT_ERROR = 6 - Fatal error during the API action * RESULT_ITEM_ALREADY_OWNED = 7 - Failure to purchase since item is already owned * RESULT_ITEM_NOT_OWNED = 8 - Failure to consume since item is not owned */ interface IInAppBillingService { /** * Checks support for the requested billing API version, package and in-app type. * Minimum API version supported by this interface is 3. * @param apiVersion billing API version that the app is using * @param packageName the package name of the calling app * @param type type of the in-app item being purchased ("inapp" for one-time purchases * and "subs" for subscriptions) * @return RESULT_OK(0) on success and appropriate response code on failures. */ int isBillingSupported(int apiVersion, String packageName, String type); /** * Provides details of a list of SKUs * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle * with a list JSON strings containing the productId, price, title and description. * This API can be called with a maximum of 20 SKUs. * @param apiVersion billing API version that the app is using * @param packageName the package name of the calling app * @param type of the in-app items ("inapp" for one-time purchases * and "subs" for subscriptions) * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST" * @return Bundle containing the following key-value pairs * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes * on failures. * "DETAILS_LIST" with a StringArrayList containing purchase information * in JSON format similar to: * '{ "productId" : "exampleSku", * "type" : "inapp", * "price" : "$5.00", * "price_currency": "USD", * "price_amount_micros": 5000000, * "title : "Example Title", * "description" : "This is an example description" }' */ Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle); /** * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU, * the type, a unique purchase token and an optional developer payload. * @param apiVersion billing API version that the app is using * @param packageName package name of the calling app * @param sku the SKU of the in-app item as published in the developer console * @param type of the in-app item being purchased ("inapp" for one-time purchases * and "subs" for subscriptions) * @param developerPayload optional argument to be sent back with the purchase information * @return Bundle containing the following key-value pairs * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes * on failures. * "BUY_INTENT" - PendingIntent to start the purchase flow * * The Pending intent should be launched with startIntentSenderForResult. When purchase flow * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. * If the purchase is successful, the result data will contain the following key-value pairs * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response * codes on failures. * "INAPP_PURCHASE_DATA" - String in JSON format similar to * '{"orderId":"12999763169054705758.1371079406387615", * "packageName":"com.example.app", * "productId":"exampleSku", * "purchaseTime":1345678900000, * "purchaseToken" : "122333444455555", * "developerPayload":"example developer payload" }' * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that * was signed with the private key of the developer */ Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload); /** * Returns the current SKUs owned by the user of the type and package name specified along with * purchase information and a signature of the data to be validated. * This will return all SKUs that have been purchased in V3 and managed items purchased using * V1 and V2 that have not been consumed. * @param apiVersion billing API version that the app is using * @param packageName package name of the calling app * @param type of the in-app items being requested ("inapp" for one-time purchases * and "subs" for subscriptions) * @param continuationToken to be set as null for the first call, if the number of owned * skus are too many, a continuationToken is returned in the response bundle. * This method can be called again with the continuation token to get the next set of * owned skus. * @return Bundle containing the following key-value pairs * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes on failures. * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures * of the purchase information * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the * next set of in-app purchases. Only set if the * user has more owned skus than the current list. */ Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken); /** * Consume the last purchase of the given SKU. This will result in this item being removed * from all subsequent responses to getPurchases() and allow re-purchase of this item. * @param apiVersion billing API version that the app is using * @param packageName package name of the calling app * @param purchaseToken token in the purchase information JSON that identifies the purchase * to be consumed * @return RESULT_OK(0) if consumption succeeded, appropriate response codes on failures. */ int consumePurchase(int apiVersion, String packageName, String purchaseToken); /** * This API is currently under development. */ int stub(int apiVersion, String packageName, String type); /** * Returns a pending intent to launch the purchase flow for upgrading or downgrading a * subscription. The existing owned SKU(s) should be provided along with the new SKU that * the user is upgrading or downgrading to. * @param apiVersion billing API version that the app is using, must be 5 or later * @param packageName package name of the calling app * @param oldSkus the SKU(s) that the user is upgrading or downgrading from, * if null or empty this method will behave like {@link #getBuyIntent} * @param newSku the SKU that the user is upgrading or downgrading to * @param type of the item being purchased, currently must be "subs" * @param developerPayload optional argument to be sent back with the purchase information * @return Bundle containing the following key-value pairs * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes * on failures. * "BUY_INTENT" - PendingIntent to start the purchase flow * * The Pending intent should be launched with startIntentSenderForResult. When purchase flow * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. * If the purchase is successful, the result data will contain the following key-value pairs * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response * codes on failures. * "INAPP_PURCHASE_DATA" - String in JSON format similar to * '{"orderId":"12999763169054705758.1371079406387615", * "packageName":"com.example.app", * "productId":"exampleSku", * "purchaseTime":1345678900000, * "purchaseToken" : "122333444455555", * "developerPayload":"example developer payload" }' * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that * was signed with the private key of the developer */ Bundle getBuyIntentToReplaceSkus(int apiVersion, String packageName, in List oldSkus, String newSku, String type, String developerPayload); /** * Returns a pending intent to launch the purchase flow for an in-app item. This method is * a variant of the {@link #getBuyIntent} method and takes an additional {@code extraParams} * parameter. This parameter is a Bundle of optional keys and values that affect the * operation of the method. * @param apiVersion billing API version that the app is using, must be 6 or later * @param packageName package name of the calling app * @param sku the SKU of the in-app item as published in the developer console * @param type of the in-app item being purchased ("inapp" for one-time purchases * and "subs" for subscriptions) * @param developerPayload optional argument to be sent back with the purchase information * @extraParams a Bundle with the following optional keys: * "skusToReplace" - List - an optional list of SKUs that the user is * upgrading or downgrading from. * Pass this field if the purchase is upgrading or downgrading * existing subscriptions. * The specified SKUs are replaced with the SKUs that the user is * purchasing. Google Play replaces the specified SKUs at the start of * the next billing cycle. * "replaceSkusProration" - Boolean - whether the user should be credited for any unused * subscription time on the SKUs they are upgrading or downgrading. * If you set this field to true, Google Play swaps out the old SKUs * and credits the user with the unused value of their subscription * time on a pro-rated basis. * Google Play applies this credit to the new subscription, and does * not begin billing the user for the new subscription until after * the credit is used up. * If you set this field to false, the user does not receive credit for * any unused subscription time and the recurrence date does not * change. * Default value is true. Ignored if you do not pass skusToReplace. * "accountId" - String - an optional obfuscated string that is uniquely * associated with the user's account in your app. * If you pass this value, Google Play can use it to detect irregular * activity, such as many devices making purchases on the same * account in a short period of time. * Do not use the developer ID or the user's Google ID for this field. * In addition, this field should not contain the user's ID in * cleartext. * We recommend that you use a one-way hash to generate a string from * the user's ID, and store the hashed string in this field. * "vr" - Boolean - an optional flag indicating whether the returned intent * should start a VR purchase flow. The apiVersion must also be 7 or * later to use this flag. */ Bundle getBuyIntentExtraParams(int apiVersion, String packageName, String sku, String type, String developerPayload, in Bundle extraParams); /** * Returns the most recent purchase made by the user for each SKU, even if that purchase is * expired, canceled, or consumed. * @param apiVersion billing API version that the app is using, must be 6 or later * @param packageName package name of the calling app * @param type of the in-app items being requested ("inapp" for one-time purchases * and "subs" for subscriptions) * @param continuationToken to be set as null for the first call, if the number of owned * skus is too large, a continuationToken is returned in the response bundle. * This method can be called again with the continuation token to get the next set of * owned skus. * @param extraParams a Bundle with extra params that would be appended into http request * query string. Not used at this moment. Reserved for future functionality. * @return Bundle containing the following key-value pairs * "RESPONSE_CODE" with int value: RESULT_OK(0) if success, * {@link IabHelper#BILLING_RESPONSE_RESULT_*} response codes on failures. * * "INAPP_PURCHASE_ITEM_LIST" - ArrayList containing the list of SKUs * "INAPP_PURCHASE_DATA_LIST" - ArrayList containing the purchase information * "INAPP_DATA_SIGNATURE_LIST"- ArrayList containing the signatures * of the purchase information * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the * next set of in-app purchases. Only set if the * user has more owned skus than the current list. */ Bundle getPurchaseHistory(int apiVersion, String packageName, String type, String continuationToken, in Bundle extraParams); /** * This method is a variant of {@link #isBillingSupported}} that takes an additional * {@code extraParams} parameter. * @param apiVersion billing API version that the app is using, must be 7 or later * @param packageName package name of the calling app * @param type of the in-app item being purchased ("inapp" for one-time purchases and "subs" * for subscriptions) * @param extraParams a Bundle with the following optional keys: * "vr" - Boolean - an optional flag to indicate whether {link #getBuyIntentExtraParams} * supports returning a VR purchase flow. * @return RESULT_OK(0) on success and appropriate response code on failures. */ int isBillingSupportedExtraParams(int apiVersion, String packageName, String type, in Bundle extraParams); } ================================================ FILE: android_mobile/src/main/java/android/support/v4/preference/PreferenceFragment.java ================================================ /* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.support.v4.preference; import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.preference.Preference; import android.preference.PreferenceGroup; import android.preference.PreferenceManager; import android.preference.PreferenceScreen; import android.support.v4.app.Fragment; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnKeyListener; import android.view.ViewGroup; import android.widget.ListView; import com.alexstyl.specialdates.R; @SuppressWarnings("all") public abstract class PreferenceFragment extends Fragment implements PreferenceManagerCompat.OnPreferenceTreeClickListener { private static final String PREFERENCES_TAG = "android:preferences"; private PreferenceManager mPreferenceManager; private ListView mList; private boolean mHavePrefs; private boolean mInitDone; /** * The starting request code given out to preference framework. */ private static final int FIRST_REQUEST_CODE = 100; private static final int MSG_BIND_PREFERENCES = 1; private Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_BIND_PREFERENCES: bindPreferences(); break; } } }; final private Runnable mRequestFocus = new Runnable() { public void run() { mList.focusableViewAvailable(mList); } }; /** * Interface that PreferenceFragment's containing activity should * implement to be able to process preference items that wish to * switch to a new fragment. */ public interface OnPreferenceStartFragmentCallback { /** * Called when the user has clicked on a Preference that has * a fragment class name associated with it. The implementation * to should instantiate and switch to an instance of the given * fragment. */ boolean onPreferenceStartFragment(PreferenceFragment caller, Preference pref); } @Override public void onCreate(Bundle paramBundle) { super.onCreate(paramBundle); mPreferenceManager = PreferenceManagerCompat.newInstance(getActivity(), FIRST_REQUEST_CODE); PreferenceManagerCompat.setFragment(mPreferenceManager, this); } @Override public void onViewCreated(View view, Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); } @Override public View onCreateView(LayoutInflater paramLayoutInflater, ViewGroup paramViewGroup, Bundle paramBundle) { return paramLayoutInflater.inflate( R.layout.preference_list_fragment, paramViewGroup, false); } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); if (mHavePrefs) { bindPreferences(); } mInitDone = true; if (savedInstanceState != null) { Bundle container = savedInstanceState.getBundle(PREFERENCES_TAG); if (container != null) { final PreferenceScreen preferenceScreen = getPreferenceScreen(); if (preferenceScreen != null) { preferenceScreen.restoreHierarchyState(container); } } } } @Override public void onStart() { super.onStart(); PreferenceManagerCompat.setOnPreferenceTreeClickListener(mPreferenceManager, this); } @Override public void onStop() { super.onStop(); PreferenceManagerCompat.dispatchActivityStop(mPreferenceManager); PreferenceManagerCompat.setOnPreferenceTreeClickListener(mPreferenceManager, null); } @Override public void onDestroyView() { mList = null; mHandler.removeCallbacks(mRequestFocus); mHandler.removeMessages(MSG_BIND_PREFERENCES); super.onDestroyView(); } @Override public void onDestroy() { super.onDestroy(); PreferenceManagerCompat.dispatchActivityDestroy(mPreferenceManager); } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); final PreferenceScreen preferenceScreen = getPreferenceScreen(); if (preferenceScreen != null) { Bundle container = new Bundle(); preferenceScreen.saveHierarchyState(container); outState.putBundle(PREFERENCES_TAG, container); } } @Override public void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); PreferenceManagerCompat.dispatchActivityResult(mPreferenceManager, requestCode, resultCode, data); } /** * Returns the {@link PreferenceManager} used by this fragment. * @return The {@link PreferenceManager}. */ public PreferenceManager getPreferenceManager() { return mPreferenceManager; } /** * Sets the root of the preference hierarchy that this fragment is showing. * * @param preferenceScreen The root {@link PreferenceScreen} of the preference hierarchy. */ public void setPreferenceScreen(PreferenceScreen preferenceScreen) { if (PreferenceManagerCompat.setPreferences(mPreferenceManager, preferenceScreen) && preferenceScreen != null) { mHavePrefs = true; if (mInitDone) { postBindPreferences(); } } } /** * Gets the root of the preference hierarchy that this fragment is showing. * * @return The {@link PreferenceScreen} that is the root of the preference * hierarchy. */ public PreferenceScreen getPreferenceScreen() { return PreferenceManagerCompat.getPreferenceScreen(mPreferenceManager); } /** * Adds preferences from activities that match the given {@link Intent}. * * @param intent The {@link Intent} to query activities. */ public void addPreferencesFromIntent(Intent intent) { requirePreferenceManager(); setPreferenceScreen(PreferenceManagerCompat.inflateFromIntent(mPreferenceManager, intent, getPreferenceScreen())); } /** * Inflates the given XML resource and adds the preference hierarchy to the current * preference hierarchy. * * @param preferencesResId The XML resource ID to inflate. */ public void addPreferencesFromResource(int preferencesResId) { requirePreferenceManager(); setPreferenceScreen(PreferenceManagerCompat.inflateFromResource(mPreferenceManager, getActivity(), preferencesResId, getPreferenceScreen())); } /** * {@inheritDoc} */ public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference) { //if (preference.getFragment() != null && if ( getActivity() instanceof OnPreferenceStartFragmentCallback) { return ((OnPreferenceStartFragmentCallback)getActivity()).onPreferenceStartFragment( this, preference); } return false; } /** * Finds a {@link Preference} based on its key. * * @param key The key of the preference to retrieve. * @return The {@link Preference} with the key, or null. * @see PreferenceGroup#findPreference(CharSequence) */ public Preference findPreference(CharSequence key) { if (mPreferenceManager == null) { return null; } return mPreferenceManager.findPreference(key); } private void requirePreferenceManager() { if (mPreferenceManager == null) { throw new RuntimeException("This should be called after super.onCreate."); } } private void postBindPreferences() { if (mHandler.hasMessages(MSG_BIND_PREFERENCES)) return; mHandler.obtainMessage(MSG_BIND_PREFERENCES).sendToTarget(); } private void bindPreferences() { final PreferenceScreen preferenceScreen = getPreferenceScreen(); if (preferenceScreen != null) { preferenceScreen.bind(getListView()); } } public ListView getListView() { ensureList(); return mList; } private void ensureList() { if (mList != null) { return; } View root = getView(); if (root == null) { throw new IllegalStateException("Content view not yet created"); } View rawListView = root.findViewById(android.R.id.list); if (!(rawListView instanceof ListView)) { throw new RuntimeException( "Content has view with id attribute 'android.R.id.list' " + "that is not a ListView class"); } mList = (ListView)rawListView; if (mList == null) { throw new RuntimeException( "Your content must have a ListView whose id attribute is " + "'android.R.id.list'"); } mList.setOnKeyListener(mListOnKeyListener); mHandler.post(mRequestFocus); } private OnKeyListener mListOnKeyListener = new OnKeyListener() { @Override public boolean onKey(View v, int keyCode, KeyEvent event) { Object selectedItem = mList.getSelectedItem(); if (selectedItem instanceof Preference) { @SuppressWarnings("unused") View selectedView = mList.getSelectedView(); //return ((Preference)selectedItem).onKey( // selectedView, keyCode, event); return false; } return false; } }; } ================================================ FILE: android_mobile/src/main/java/android/support/v4/preference/PreferenceManagerCompat.java ================================================ /* * Copyright (C) 2013 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.support.v4.preference; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.preference.Preference; import android.preference.PreferenceManager; import android.preference.PreferenceScreen; import android.util.Log; public class PreferenceManagerCompat { private static final String TAG = PreferenceManagerCompat.class.getSimpleName(); /** * Interface definition for a callback to be invoked when a * {@link Preference} in the hierarchy rooted at this {@link PreferenceScreen} is * clicked. */ interface OnPreferenceTreeClickListener { /** * Called when a preference in the tree rooted at this * {@link PreferenceScreen} has been clicked. * * @param preferenceScreen The {@link PreferenceScreen} that the * preference is located in. * @param preference The preference that was clicked. * @return Whether the click was handled. */ boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, Preference preference); } static PreferenceManager newInstance(Activity activity, int firstRequestCode) { try { Constructor c = PreferenceManager.class.getDeclaredConstructor(Activity.class, int.class); c.setAccessible(true); return c.newInstance(activity, firstRequestCode); } catch (Exception e) { Log.w(TAG, "Couldn't call constructor PreferenceManager by reflection", e); } return null; } /** * Sets the owning preference fragment */ static void setFragment(PreferenceManager manager, PreferenceFragment fragment) { // stub } /** * Sets the callback to be invoked when a {@link Preference} in the * hierarchy rooted at this {@link PreferenceManager} is clicked. * * @param listener The callback to be invoked. */ static void setOnPreferenceTreeClickListener(PreferenceManager manager, final OnPreferenceTreeClickListener listener) { try { Field onPreferenceTreeClickListener = PreferenceManager.class.getDeclaredField("mOnPreferenceTreeClickListener"); onPreferenceTreeClickListener.setAccessible(true); if (listener != null) { Object proxy = Proxy.newProxyInstance( onPreferenceTreeClickListener.getType().getClassLoader(), new Class[] { onPreferenceTreeClickListener.getType() }, new InvocationHandler() { public Object invoke(Object proxy, Method method, Object[] args) { if (method.getName().equals("onPreferenceTreeClick")) { return Boolean.valueOf(listener.onPreferenceTreeClick((PreferenceScreen) args[0], (Preference) args[1])); } else { return null; } } }); onPreferenceTreeClickListener.set(manager, proxy); } else { onPreferenceTreeClickListener.set(manager, null); } } catch (Exception e) { Log.w(TAG, "Couldn't set PreferenceManager.mOnPreferenceTreeClickListener by reflection", e); } } /** * Inflates a preference hierarchy from the preference hierarchies of * {@link Activity Activities} that match the given {@link Intent}. An * {@link Activity} defines its preference hierarchy with meta-data using * the {@link #METADATA_KEY_PREFERENCES} key. *

* If a preference hierarchy is given, the new preference hierarchies will * be merged in. * * @param queryIntent The intent to match activities. * @param rootPreferences Optional existing hierarchy to merge the new * hierarchies into. * @return The root hierarchy (if one was not provided, the new hierarchy's * root). */ static PreferenceScreen inflateFromIntent(PreferenceManager manager, Intent intent, PreferenceScreen screen) { try { Method m = PreferenceManager.class.getDeclaredMethod("inflateFromIntent", Intent.class, PreferenceScreen.class); m.setAccessible(true); PreferenceScreen prefScreen = (PreferenceScreen) m.invoke(manager, intent, screen); return prefScreen; } catch (Exception e) { Log.w(TAG, "Couldn't call PreferenceManager.inflateFromIntent by reflection", e); } return null; } /** * Inflates a preference hierarchy from XML. If a preference hierarchy is * given, the new preference hierarchies will be merged in. * * @param context The context of the resource. * @param resId The resource ID of the XML to inflate. * @param rootPreferences Optional existing hierarchy to merge the new * hierarchies into. * @return The root hierarchy (if one was not provided, the new hierarchy's * root). * @hide */ static PreferenceScreen inflateFromResource(PreferenceManager manager, Activity activity, int resId, PreferenceScreen screen) { try { Method m = PreferenceManager.class.getDeclaredMethod("inflateFromResource", Context.class, int.class, PreferenceScreen.class); m.setAccessible(true); PreferenceScreen prefScreen = (PreferenceScreen) m.invoke(manager, activity, resId, screen); return prefScreen; } catch (Exception e) { Log.w(TAG, "Couldn't call PreferenceManager.inflateFromResource by reflection", e); } return null; } /** * Returns the root of the preference hierarchy managed by this class. * * @return The {@link PreferenceScreen} object that is at the root of the hierarchy. */ static PreferenceScreen getPreferenceScreen(PreferenceManager manager) { try { Method m = PreferenceManager.class.getDeclaredMethod("getPreferenceScreen"); m.setAccessible(true); return (PreferenceScreen) m.invoke(manager); } catch (Exception e) { Log.w(TAG, "Couldn't call PreferenceManager.getPreferenceScreen by reflection", e); } return null; } /** * Called by the {@link PreferenceManager} to dispatch a subactivity result. */ static void dispatchActivityResult(PreferenceManager manager, int requestCode, int resultCode, Intent data) { try { Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityResult", int.class, int.class, Intent.class); m.setAccessible(true); m.invoke(manager, requestCode, resultCode, data); } catch (Exception e) { Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityResult by reflection", e); } } /** * Called by the {@link PreferenceManager} to dispatch the activity stop * event. */ static void dispatchActivityStop(PreferenceManager manager) { try { Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityStop"); m.setAccessible(true); m.invoke(manager); } catch (Exception e) { Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityStop by reflection", e); } } /** * Called by the {@link PreferenceManager} to dispatch the activity destroy * event. */ static void dispatchActivityDestroy(PreferenceManager manager) { try { Method m = PreferenceManager.class.getDeclaredMethod("dispatchActivityDestroy"); m.setAccessible(true); m.invoke(manager); } catch (Exception e) { Log.w(TAG, "Couldn't call PreferenceManager.dispatchActivityDestroy by reflection", e); } } /** * Sets the root of the preference hierarchy. * * @param preferenceScreen The root {@link PreferenceScreen} of the preference hierarchy. * @return Whether the {@link PreferenceScreen} given is different than the previous. */ static boolean setPreferences(PreferenceManager manager, PreferenceScreen screen) { try { Method m = PreferenceManager.class.getDeclaredMethod("setPreferences", PreferenceScreen.class); m.setAccessible(true); return ((Boolean) m.invoke(manager, screen)); } catch (Exception e) { Log.w(TAG, "Couldn't call PreferenceManager.setPreferences by reflection", e); } return false; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/android/Bitmap.kt ================================================ package com.alexstyl.android import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri import java.net.URI fun Drawable.toBitmap(): Bitmap { if (this is BitmapDrawable) { return bitmap } val width = if (bounds.isEmpty) intrinsicWidth else bounds.width() val height = if (bounds.isEmpty) intrinsicHeight else bounds.height() return Bitmap.createBitmap(width.nonZero(), height.nonZero(), Bitmap.Config.ARGB_8888).also { val canvas = Canvas(it) setBounds(0, 0, canvas.width, canvas.height) draw(canvas) } } private fun Int.nonZero() = if (this <= 0) 1 else this ================================================ FILE: android_mobile/src/main/java/com/alexstyl/android/SimpleAnimatorListener.java ================================================ package com.alexstyl.android; import android.animation.Animator; public class SimpleAnimatorListener implements Animator.AnimatorListener { @Override public void onAnimationStart(Animator animator) { // do nothing } @Override public void onAnimationEnd(Animator animator) { // do nothing } @Override public void onAnimationCancel(Animator animator) { // do nothing } @Override public void onAnimationRepeat(Animator animator) { // do nothing } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/android/Uri.kt ================================================ package com.alexstyl.android import android.net.Uri import java.net.URI fun URI.toUri(): Uri = Uri.parse(this.toString()) fun Uri.toURI(): URI = URI.create(this.toString()) ================================================ FILE: android_mobile/src/main/java/com/alexstyl/android/preferences/PreferenceKeyId.java ================================================ package com.alexstyl.android.preferences; public @interface PreferenceKeyId { } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/resources/AndroidColors.kt ================================================ package com.alexstyl.resources import android.content.Context import android.support.v4.content.ContextCompat import com.alexstyl.specialdates.R import com.alexstyl.specialdates.events.database.EventTypeId import com.alexstyl.specialdates.events.peopleevents.EventType class AndroidColors(private val context: Context) : Colors { override fun getColorFor(eventType: EventType) = when (eventType.id) { EventTypeId.TYPE_BIRTHDAY -> ContextCompat.getColor(context, R.color.birthday_red) EventTypeId.TYPE_NAMEDAY -> ContextCompat.getColor(context, R.color.nameday_blue) EventTypeId.TYPE_ANNIVERSARY -> ContextCompat.getColor(context, R.color.anniversary_yellow) EventTypeId.TYPE_CUSTOM -> ContextCompat.getColor(context, R.color.purple_custom_event) EventTypeId.TYPE_OTHER -> ContextCompat.getColor(context, R.color.purple_custom_event) else -> { throw IllegalStateException("No color matching for $eventType") } } override fun getDateHeaderTextColor(): Int = ContextCompat.getColor(context, R.color.upcoming_header_text_color) override fun getTodayHeaderTextColor(): Int = ContextCompat.getColor(context, R.color.upcoming_header_today_text_color) override fun getDailyReminderColor(): Int = ContextCompat.getColor(context, R.color.main_red) override fun getNamedaysColor(): Int = ContextCompat.getColor(context, R.color.nameday_blue) override fun getBankholidaysColor(): Int = ContextCompat.getColor(context, R.color.bankholiday_green) } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/resources/ResourcesModule.java ================================================ package com.alexstyl.resources; import android.content.Context; import android.content.res.Resources; import com.alexstyl.specialdates.AndroidStrings; import com.alexstyl.specialdates.Strings; import javax.inject.Singleton; import dagger.Module; import dagger.Provides; @Module @Singleton public class ResourcesModule { private final Resources resources; private final Context context; public ResourcesModule(Context context, Resources resources) { this.context = context; this.resources = resources; } @Provides @Singleton Strings providesString() { return new AndroidStrings(resources); } @Provides @Singleton DimensionResources providesDimensionResources() { return new AndroidDimensionResources(resources); } @Provides @Singleton Colors providesColorResources() { return new AndroidColors(context); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/AndroidApplicationModule.java ================================================ package com.alexstyl.specialdates; import android.app.AlarmManager; import android.app.NotificationManager; import android.appwidget.AppWidgetManager; import android.content.ContentResolver; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Resources; import com.alexstyl.Logger; import com.alexstyl.android.AndroidLogger; import com.alexstyl.specialdates.dailyreminder.AlarmManagerCompat; import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper; import com.alexstyl.specialdates.permissions.AndroidPermissions; import com.alexstyl.specialdates.permissions.MementoPermissions; import com.evernote.android.job.JobManager; import javax.inject.Singleton; import dagger.Module; import dagger.Provides; @Module class AndroidApplicationModule { private final Context context; AndroidApplicationModule(Context appContext) { this.context = appContext; } @Provides Context appContext() { return context; } @Provides Resources resources() { return context.getResources(); } @Provides PackageManager pkgManager() { return context.getPackageManager(); } @Provides ContentResolver contentResolver() { return context.getContentResolver(); } @Provides AppWidgetManager appWidgetManager() { return AppWidgetManager.getInstance(context); } @Provides @Singleton EventSQLiteOpenHelper sqLiteOpenHelper() { return new EventSQLiteOpenHelper(context); } @Provides MementoPermissions permissionChecker(CrashAndErrorTracker tracker) { return new AndroidPermissions(tracker, context); } @Provides CrashAndErrorTracker tracker() { return new FabricTracker(context); } @Provides @Singleton JobManager jobManager() { return JobManager.create(context); } @Provides NotificationManager notificationManager() { return (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); } @Provides Logger logger() { return new AndroidLogger(); } @Provides AlarmManagerCompat alarmManagerCompat() { return new AlarmManagerCompat((AlarmManager) context.getSystemService(Context.ALARM_SERVICE)); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/AppComponent.java ================================================ package com.alexstyl.specialdates; import com.alexstyl.resources.ResourcesModule; import com.alexstyl.specialdates.addevent.AddEventActivity; import com.alexstyl.specialdates.addevent.AddEventModule; import com.alexstyl.specialdates.addevent.EventDatePickerDialogFragment; import com.alexstyl.specialdates.addevent.ui.ContactSuggestionView; import com.alexstyl.specialdates.analytics.AnalyticsModule; import com.alexstyl.specialdates.contact.ContactsModule; import com.alexstyl.specialdates.dailyreminder.actions.PersonActionsActivity; import com.alexstyl.specialdates.dailyreminder.DailyReminderModule; import com.alexstyl.specialdates.dailyreminder.actions.ContactActionsModule; import com.alexstyl.specialdates.date.DateModule; import com.alexstyl.specialdates.donate.DonateActivity; import com.alexstyl.specialdates.donate.DonateModule; import com.alexstyl.specialdates.events.bankholidays.BankHolidaysModule; import com.alexstyl.specialdates.events.namedays.NamedayModule; import com.alexstyl.specialdates.events.namedays.activity.NamedaysOnADayActivity; import com.alexstyl.specialdates.events.namedays.activity.NamedaysInADayModule; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsModule; import com.alexstyl.specialdates.facebook.FacebookModule; import com.alexstyl.specialdates.facebook.FacebookProfileActivity; import com.alexstyl.specialdates.facebook.friendimport.FacebookFriendsIntentService; import com.alexstyl.specialdates.facebook.login.FacebookLogInActivity; import com.alexstyl.specialdates.facebook.login.FacebookWebView; import com.alexstyl.specialdates.home.HomeActivity; import com.alexstyl.specialdates.images.ImageModule; import com.alexstyl.specialdates.people.PeopleFragment; import com.alexstyl.specialdates.people.PeopleModule; import com.alexstyl.specialdates.permissions.ContactPermissionActivity; import com.alexstyl.specialdates.person.PersonActivity; import com.alexstyl.specialdates.person.PersonModule; import com.alexstyl.specialdates.receiver.BootCompleteReceiver; import com.alexstyl.specialdates.search.SearchActivity; import com.alexstyl.specialdates.search.SearchModule; import com.alexstyl.specialdates.settings.DailyReminderFragment; import com.alexstyl.specialdates.settings.UserSettingsFragment; import com.alexstyl.specialdates.settings.NamedayListPreference; import com.alexstyl.specialdates.support.RateDialog; import com.alexstyl.specialdates.theming.ThemingModule; import com.alexstyl.specialdates.ui.base.ThemedMementoActivity; import com.alexstyl.specialdates.ui.widget.ColorImageView; import com.alexstyl.specialdates.ui.widget.ViewModule; import com.alexstyl.specialdates.upcoming.UpcomingEventsFragment; import com.alexstyl.specialdates.upcoming.UpcomingEventsModule; import com.alexstyl.specialdates.upcoming.widget.RecentUpcomingPeopleEventsModule; import com.alexstyl.specialdates.upcoming.widget.list.UpcomingEventsRemoteViewService; import com.alexstyl.specialdates.upcoming.widget.list.UpcomingEventsScrollingAppWidgetProvider; import com.alexstyl.specialdates.upcoming.widget.list.WidgetRouterActivity; import com.alexstyl.specialdates.upcoming.widget.today.TodayAppWidgetProvider; import com.alexstyl.specialdates.upcoming.widget.today.UpcomingWidgetConfigureActivity; import com.alexstyl.specialdates.wear.WearSyncService; import javax.inject.Singleton; import org.jetbrains.annotations.NotNull; import dagger.Component; @Singleton @Component(modules = { AndroidApplicationModule.class, AnalyticsModule.class, ContactActionsModule.class, ResourcesModule.class, ContactsModule.class, DateModule.class, ImageModule.class, ViewModule.class, NamedayModule.class, UpcomingEventsModule.class, NamedaysInADayModule.class, DailyReminderModule.class, DonateModule.class, SearchModule.class, PeopleEventsModule.class, BankHolidaysModule.class, AddEventModule.class, FacebookModule.class, PeopleModule.class, BankHolidaysModule.class, ThemingModule.class, RecentUpcomingPeopleEventsModule.class, PersonModule.class }) public interface AppComponent { void inject(MementoApplication application); void inject(HomeActivity activity); void inject(UpcomingEventsFragment fragment); void inject(AddEventActivity activity); void inject(SearchActivity activity); void inject(FacebookProfileActivity activity); void inject(FacebookLogInActivity activity); void inject(RateDialog activity); void inject(UserSettingsFragment fragment); void inject(DailyReminderFragment fragment); void inject(DonateActivity activity); void inject(UpcomingEventsScrollingAppWidgetProvider widgetProvider); void inject(TodayAppWidgetProvider widgetProvider); void inject(PersonActivity activity); void inject(NamedaysOnADayActivity activity); void inject(EventDatePickerDialogFragment fragment); void inject(UpcomingEventsRemoteViewService viewService); void inject(ContactSuggestionView view); void inject(NamedayListPreference preference); void inject(WearSyncService service); void inject(ColorImageView view); void inject(UpcomingWidgetConfigureActivity activity); void inject(WidgetRouterActivity widgetRouterActivity); void inject(DeviceConfigurationUpdatedReceiver receiver); void inject(FacebookFriendsIntentService service); void inject(ContactPermissionActivity activity); void inject(BootCompleteReceiver receiver); void inject(PeopleFragment peopleFragment); void inject(FacebookWebView peopleFragment); void inject(@NotNull ThemedMementoActivity themedMementoActivity); void inject(@NotNull PersonActionsActivity personActionsActivity); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/DeviceConfigurationUpdatedReceiver.java ================================================ package com.alexstyl.specialdates; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsViewRefresher; import javax.inject.Inject; /** * A {@linkplain BroadcastReceiver} that keeps track whether the user has updated some option on their device * external to Memento which can affect the app. */ public class DeviceConfigurationUpdatedReceiver extends BroadcastReceiver { @Inject UpcomingEventsViewRefresher viewRefresher; @Inject CrashAndErrorTracker tracker; @Override public void onReceive(Context context, Intent intent) { ((MementoApplication) context.getApplicationContext()).getApplicationModule().inject(this); String action = intent.getAction(); if (Intent.ACTION_LOCALE_CHANGED.equals(action)) { tracker.updateLocaleUsed(); viewRefresher.refreshViews(); } else if (Intent.ACTION_DATE_CHANGED.equals(action)) { viewRefresher.refreshViews(); } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/EasyPreferences.java ================================================ package com.alexstyl.specialdates; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.preference.PreferenceManager; import android.support.annotation.BoolRes; import android.support.annotation.StringRes; import android.support.v4.util.Pair; public final class EasyPreferences { private final SharedPreferences prefs; private final Resources res; public static EasyPreferences createForDefaultPreferences(Context context) { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); Resources resources = context.getResources(); return new EasyPreferences(preferences, resources); } public static EasyPreferences createForPrivatePreferences(Context context, @StringRes int fileName) { SharedPreferences preferences = context.getSharedPreferences(context.getString(fileName), Context.MODE_PRIVATE); Resources resources = context.getResources(); return new EasyPreferences(preferences, resources); } private EasyPreferences(SharedPreferences preferences, Resources resources) { this.prefs = preferences; this.res = resources; } private String key(int key) { return res.getString(key); } public boolean getBoolean(@StringRes int bool, boolean defValue) { return prefs.getBoolean(key(bool), defValue); } public boolean getBoolean(@StringRes int bool, @BoolRes int fallbackDefaultValue) { boolean contains = prefs.contains(key(bool)); if (contains) { return prefs.getBoolean(key(bool), false); } return res.getBoolean(fallbackDefaultValue); } public void setBoolean(@StringRes int key, boolean value) { prefs.edit().putBoolean(key(key), value).apply(); } public void setString(@StringRes int key, String value) { prefs.edit().putString(key(key), value).apply(); } public void setInteger(@StringRes int key, int value) { prefs.edit().putInt(key(key), value).apply(); } public int getInt(@StringRes int key, int defValue) { return prefs.getInt(key(key), defValue); } public long getLong(@StringRes int key, long defValue) { return prefs.getLong(key(key), defValue); } public String getString(@StringRes int key, String defValue) { return prefs.getString(key(key), defValue); } public float getFloat(@StringRes int key, float defValue) { return prefs.getFloat(key(key), defValue); } public void setLong(@StringRes int key, long value) { prefs.edit().putLong(key(key), value).apply(); } public void clear() { prefs.edit().clear().apply(); } public void setFloat(@StringRes int key, float value) { prefs.edit().putFloat(key(key), value).apply(); } public void setIntegers(Pair firstPair, Pair... otherPairs) { SharedPreferences.Editor edit = prefs.edit(); edit.putInt(key(firstPair.first), firstPair.second); for (Pair pair : otherPairs) { edit.putInt(key(pair.first), pair.second); } edit.apply(); } public void addOnPreferenceChangedListener(SharedPreferences.OnSharedPreferenceChangeListener listener) { prefs.registerOnSharedPreferenceChangeListener(listener); } public void removeOnPreferenceChagnedListener(SharedPreferences.OnSharedPreferenceChangeListener listener) { prefs.unregisterOnSharedPreferenceChangeListener(listener); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/ExternalNavigator.java ================================================ package com.alexstyl.specialdates; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.net.Uri; import android.provider.ContactsContract.Contacts; import android.widget.Toast; import com.alexstyl.specialdates.analytics.Analytics; import com.alexstyl.specialdates.analytics.Screen; import com.alexstyl.specialdates.contact.Contact; import com.alexstyl.specialdates.contact.ContactSource; import com.alexstyl.specialdates.person.BottomSheetIntentDialog; import com.alexstyl.specialdates.ui.base.MementoActivity; import com.novoda.simplechromecustomtabs.SimpleChromeCustomTabs; import java.util.ArrayList; import java.util.List; public class ExternalNavigator { private final MementoActivity activity; private final Analytics analytics; private final CrashAndErrorTracker tracker; public ExternalNavigator(MementoActivity activity, Analytics analytics, CrashAndErrorTracker tracker) { this.activity = activity; this.analytics = analytics; this.tracker = tracker; SimpleChromeCustomTabs.initialize(activity); } private Uri createPlayStoreUri() { String packageName = activity.getPackageName(); return Uri.parse("market://details?id=" + packageName); } public void toPlayStore() { try { Intent intent = new Intent(Intent.ACTION_VIEW, createPlayStoreUri()); activity.startActivity(intent); analytics.trackScreen(Screen.PLAY_STORE); } catch (ActivityNotFoundException e) { tracker.track(e); } } public void connectTo(Activity activity) { SimpleChromeCustomTabs.getInstance().connectTo(activity); } public void disconnectTo(Activity activity) { SimpleChromeCustomTabs.getInstance().disconnectFrom(activity); } public void toContactDetails(Contact contact) { if (contact.getSource() == ContactSource.SOURCE_FACEBOOK) { toFacebookContactDetails(contact); } else if (contact.getSource() == ContactSource.SOURCE_DEVICE) { toDeviceContactDetails(contact); } else { throw new IllegalStateException("Invalid contact source " + contact.getSource()); } } private void toFacebookContactDetails(Contact contact) { try { Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse("https://www.facebook.com/" + contact.getContactID())); activity.startActivity(intent); } catch (ActivityNotFoundException ex) { Toast.makeText(activity, R.string.no_app_found, Toast.LENGTH_SHORT).show(); } } private void toDeviceContactDetails(Contact contact) { try { ArrayList intents = viewContactIntentsFromOtherApps(contact); if (intents.size() == 1) { activity.startActivity(intents.get(0)); } else if (intents.size() > 1) { // show bottom sheet with options BottomSheetIntentDialog bottomSheetPicturesDialog = BottomSheetIntentDialog.Companion.newIntent( activity.getString(R.string.View_contact), intents); bottomSheetPicturesDialog.show(activity.getSupportFragmentManager(), "CONTACT"); } } catch (ActivityNotFoundException ex) { Toast.makeText(activity, R.string.no_app_found, Toast.LENGTH_SHORT).show(); } } private ArrayList viewContactIntentsFromOtherApps(Contact contact) { PackageManager packageManager = activity.getPackageManager(); Intent intent = viewContact(contact); List activities = packageManager.queryIntentActivities(intent, 0); String myPackage = activity.getPackageName(); ArrayList targetIntents = new ArrayList<>(); for (ResolveInfo currentInfo : activities) { String packageName = currentInfo.activityInfo.packageName; if (!myPackage.equals(packageName)) { Intent targetIntent = viewContact(contact); intent.setPackage(packageName); targetIntent.setClassName(packageName, currentInfo.activityInfo.name); targetIntents.add(targetIntent); } } return targetIntents; } private Intent viewContact(Contact contact) { Intent intent = new Intent(Intent.ACTION_VIEW); Uri uri = Uri.withAppendedPath(Contacts.CONTENT_URI, String.valueOf(contact.getContactID())); intent.setData(uri); return intent; } public void toFacebookPage() { try { Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(Uri.parse("https://www.facebook.com/memento.calendar/")); activity.startActivity(intent); } catch (ActivityNotFoundException ex) { Toast.makeText(activity, R.string.no_app_found, Toast.LENGTH_SHORT) .show(); } analytics.trackScreen(Screen.FACEBOOK_PAGE); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/FabricTracker.java ================================================ package com.alexstyl.specialdates; import android.content.Context; import android.support.annotation.NonNull; import com.alexstyl.specialdates.events.namedays.NamedayLocale; import com.crashlytics.android.Crashlytics; import com.novoda.notils.logger.simple.Log; import java.util.Locale; import io.fabric.sdk.android.Fabric; public final class FabricTracker implements CrashAndErrorTracker { private static final String TAG = FabricTracker.class.getSimpleName(); private static final String KEY_LOCALE = "user_locale"; private static final String KEY_NAMEDAY_LOCALE = "nameday_locale"; private static final String NONE = "no_locale"; private static boolean hasBeenInitialised = false; private final Context context; FabricTracker(Context context) { this.context = context; } @Override public void startTracking() { if (BuildConfig.DEBUG) { Log.d(TAG, "Ignoring Crash Tracking in DEBUG builds"); return; } Fabric.with(context, new Crashlytics()); Log.i(TAG, "Crashlytics tracking started "); hasBeenInitialised = true; Crashlytics.setString(KEY_LOCALE, String.valueOf(Locale.getDefault())); } @Override public void track(@NonNull Throwable e) { if (hasBeenInitialised) { Crashlytics.logException(e); } Log.w(e); } @Override public void onNamedayLocaleChanged(NamedayLocale locale) { if (hasBeenInitialised) { Crashlytics.setString(KEY_NAMEDAY_LOCALE, locale == null ? NONE : locale.name()); } } @Override public void updateLocaleUsed() { if (hasBeenInitialised) { Crashlytics.setString(KEY_LOCALE, String.valueOf(Locale.getDefault())); } } @Override public void log(String message) { if (hasBeenInitialised) { Crashlytics.log(message); } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/JobsCreator.kt ================================================ package com.alexstyl.specialdates import com.alexstyl.specialdates.dailyreminder.DailyReminderJob import com.alexstyl.specialdates.dailyreminder.DailyReminderNotifier import com.alexstyl.specialdates.dailyreminder.DailyReminderPresenter import com.alexstyl.specialdates.events.peopleevents.PeopleEventsUpdater import com.alexstyl.specialdates.upcoming.PeopleEventsRefreshJob import com.evernote.android.job.Job import com.evernote.android.job.JobCreator class JobsCreator(private val peopleEventsUpdater: PeopleEventsUpdater, private val presenter: DailyReminderPresenter, private val notifier: DailyReminderNotifier) : JobCreator { override fun create(tag: String): Job? = when (tag) { PeopleEventsRefreshJob.TAG -> PeopleEventsRefreshJob(peopleEventsUpdater) DailyReminderJob.TAG -> DailyReminderJob(presenter, notifier) else -> { null } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/MementoApplication.java ================================================ package com.alexstyl.specialdates; import android.app.AlarmManager; import android.content.Context; import android.support.multidex.MultiDexApplication; import com.alexstyl.resources.ResourcesModule; import com.alexstyl.specialdates.dailyreminder.DailyReminderScheduler; import com.alexstyl.specialdates.dailyreminder.DailyReminderUserSettings; import com.alexstyl.specialdates.events.namedays.activity.NamedaysInADayModule; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsModule; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsUpdater; import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsSettings; import com.alexstyl.specialdates.facebook.FacebookModule; import com.alexstyl.specialdates.facebook.FacebookUserSettings; import com.alexstyl.specialdates.facebook.friendimport.FacebookFriendsScheduler; import com.alexstyl.specialdates.images.AndroidContactsImageDownloader; import com.alexstyl.specialdates.images.ImageModule; import com.alexstyl.specialdates.images.NutraBaseImageDecoder; import com.alexstyl.specialdates.permissions.MementoPermissions; import com.alexstyl.specialdates.theming.ThemingModule; import com.alexstyl.specialdates.ui.widget.ViewModule; import com.alexstyl.specialdates.upcoming.PeopleEventsRefreshJob; import com.evernote.android.job.DailyJob; import com.evernote.android.job.JobManager; import com.evernote.android.job.JobRequest; import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; import com.nostra13.universalimageloader.core.assist.QueueProcessingType; import com.nostra13.universalimageloader.utils.L; import com.novoda.notils.logger.simple.Log; import javax.inject.Inject; import java.util.concurrent.TimeUnit; import net.danlew.android.joda.JodaTimeAndroid; public class MementoApplication extends MultiDexApplication { private AppComponent appComponent; @Inject CrashAndErrorTracker tracker; @Inject FacebookUserSettings facebookSettings; @Inject JobsCreator jobCreator; @Inject PeopleEventsUpdater peopleEventsUpdater; @Inject MementoPermissions permissions; @Inject UpcomingEventsSettings settings; @Inject DailyReminderUserSettings dailyReminderUserSettings; @Inject DailyReminderScheduler androidDailyReminderScheduler; @Override public void onCreate() { super.onCreate(); appComponent = DaggerAppComponent.builder() .androidApplicationModule(new AndroidApplicationModule(this)) .resourcesModule(new ResourcesModule(this, getResources())) .imageModule(new ImageModule(getResources())) .peopleEventsModule(new PeopleEventsModule(this)) .themingModule(new ThemingModule()) .viewModule(new ViewModule(getResources())) .facebookModule(new FacebookModule(this)) .namedaysInADayModule(new NamedaysInADayModule()) .build(); appComponent.inject(this); initialiseDependencies(); tracker.startTracking(); JobManager.create(this).addJobCreator(jobCreator); if (dailyReminderUserSettings.isEnabled()) { androidDailyReminderScheduler.scheduleReminderFor(dailyReminderUserSettings.getTimeSet()); } if (facebookSettings.isLoggedIn()) { AlarmManager alarmManager = (AlarmManager) getSystemService(ALARM_SERVICE); new FacebookFriendsScheduler(this, alarmManager).scheduleNext();// TODO use job schedulerAndroid } if (needsToInitialiseEvents()) { peopleEventsUpdater .updateEvents() .subscribe(); } schedulePeopleEventJob(); } private boolean needsToInitialiseEvents() { return permissions.canReadAndWriteContacts() && !settings.hasBeenInitialised(); } private void schedulePeopleEventJob() { DailyJob.schedule( new JobRequest.Builder(PeopleEventsRefreshJob.TAG), TimeUnit.HOURS.toMillis(1), TimeUnit.HOURS.toMillis(3) ); } protected void initialiseDependencies() { Log.setShowLogs(BuildConfig.DEBUG); JodaTimeAndroid.init(this); initImageLoader(this); } @SuppressWarnings("MagicNumber") public static void initImageLoader(Context context) { ImageLoaderConfiguration.Builder config = new ImageLoaderConfiguration.Builder(context) .threadPriority(Thread.MIN_PRIORITY) .threadPoolSize(10) .tasksProcessingOrder(QueueProcessingType.LIFO) .imageDecoder(new NutraBaseImageDecoder(BuildConfig.DEBUG)) .imageDownloader(new AndroidContactsImageDownloader(context)); L.writeLogs(BuildConfig.DEBUG); com.nostra13.universalimageloader.core.ImageLoader.getInstance().init(config.build()); } public AppComponent getApplicationModule() { return appComponent; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/MementoConstants.kt ================================================ package com.alexstyl.specialdates object MementoConstants { var PACKAGE = BuildConfig.APPLICATION_ID } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/SQLArgumentBuilder.java ================================================ package com.alexstyl.specialdates; import com.alexstyl.specialdates.date.Date; public final class SQLArgumentBuilder { private static final int TEN = 10; private SQLArgumentBuilder() { // hide this } public static String dateWithoutYear(Date date) { StringBuilder stringBuilder = new StringBuilder(); int month = date.getMonth(); addWithLeadingZeroIfNeeded(stringBuilder, month); stringBuilder.append("-"); addWithLeadingZeroIfNeeded(stringBuilder, date.getDayOfMonth()); return stringBuilder.toString(); } private static void addWithLeadingZeroIfNeeded(StringBuilder stringBuilder, int value) { if (value < TEN) { stringBuilder.append("0"); } stringBuilder.append(value); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/ShareAppIntentCreator.java ================================================ package com.alexstyl.specialdates; import android.content.Intent; public class ShareAppIntentCreator { private static final String MARKET_LINK_SHORT = "http://goo.gl/ZQiAsi"; private final Strings strings; public ShareAppIntentCreator(Strings strings) { this.strings = strings; } public Intent buildIntent() { Intent intent = new Intent(Intent.ACTION_SEND); intent.setType("text/plain"); intent.putExtra(Intent.EXTRA_TEXT, createShareText()); return intent; } private String createShareText() { return String.format(strings.shareText(), strings.appName(), MARKET_LINK_SHORT); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/AccountData.java ================================================ package com.alexstyl.specialdates.addevent; import android.graphics.drawable.Drawable; public class AccountData { static final AccountData NO_ACCOUNT = new AccountData(null, null, null); private final String name; private final String type; private final Drawable icon; AccountData(String name, String type, Drawable icon) { this.name = name; this.type = type; this.icon = icon; } String getAccountName() { return name; } String getAccountType() { return type; } Drawable getIcon() { return icon; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/AddEventActivity.kt ================================================ package com.alexstyl.specialdates.addevent import android.Manifest import android.annotation.TargetApi import android.app.Activity import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.ContactsContract import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView import android.view.MenuItem import android.view.View import com.alexstyl.android.toURI import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.MementoApplication import com.alexstyl.specialdates.R import com.alexstyl.specialdates.addevent.EventDatePickerDialogFragment.OnEventDatePickedListener import com.alexstyl.specialdates.addevent.bottomsheet.BottomSheetPicturesDialog import com.alexstyl.specialdates.addevent.bottomsheet.BottomSheetPicturesDialog.Listener import com.alexstyl.specialdates.addevent.bottomsheet.PhotoPickerViewModel import com.alexstyl.specialdates.addevent.ui.AvatarPickerView import com.alexstyl.specialdates.analytics.Analytics import com.alexstyl.specialdates.analytics.Screen import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.date.Date import com.alexstyl.specialdates.events.peopleevents.EventType import com.alexstyl.specialdates.events.peopleevents.ShortDateLabelCreator import com.alexstyl.specialdates.images.ImageLoader import com.alexstyl.specialdates.permissions.MementoPermissions import com.alexstyl.specialdates.ui.base.ThemedMementoActivity import com.alexstyl.specialdates.ui.widget.MementoToolbar import com.theartofdev.edmodo.cropper.CropImage import com.theartofdev.edmodo.cropper.CropImageView import java.net.URI import javax.inject.Inject class AddEventActivity : ThemedMementoActivity(), Listener, OnEventDatePickedListener, DiscardPromptDialog.Listener { lateinit var presenter: AddEventsPresenter @Inject set lateinit var permissionChecker: MementoPermissions @Inject set lateinit var uriFilePathProvider: UriFilePathProvider @Inject set lateinit var analytics: Analytics @Inject set lateinit var imageLoader: ImageLoader @Inject set lateinit var tracker: CrashAndErrorTracker @Inject set lateinit var shortDateLabelCreator: ShortDateLabelCreator @Inject set lateinit var view: AddEventView public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) overridePendingTransition(R.anim.slide_in_from_below, R.anim.stay) setContentView(R.layout.activity_add_event) val applicationModule = (application as MementoApplication).applicationModule applicationModule.inject(this) analytics.trackScreen(Screen.ADD_EVENT) val toolbar = findViewById(R.id.memento_toolbar) setSupportActionBar(toolbar) toolbar.displayNavigationIconAsClose() val avatarView = findViewById(R.id.add_event_avatar) val eventsView = findViewById(R.id.add_event_events) eventsView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false) eventsView.setHasFixedSize(true) val adapter = ContactEventsAdapter(contactDetailsListener) eventsView.adapter = adapter avatarView.setOnClickListener { if (avatarView.isDisplayingAvatar) { if (permissionChecker.canReadExternalStorage()) { BottomSheetPicturesDialog .includeClearImageOption() .show(supportFragmentManager, "picture_pick") } else { requestExternalStoragePermission() } } else { if (permissionChecker.canReadExternalStorage()) { BottomSheetPicturesDialog.newInstance() .show(supportFragmentManager, "picture_pick") } else { requestExternalStoragePermission() } } } val saveButton = findViewById(R.id.add_event_save) saveButton.setOnClickListener { presenter.saveChanges() finishActivitySuccessfully() } view = AndroidAddEventView(avatarView, adapter, imageLoader, createToolbarAnimator(toolbar), saveButton) presenter.startPresentingInto(view) } @TargetApi(Build.VERSION_CODES.M) private fun requestExternalStoragePermission() { requestPermissions( arrayOf( Manifest.permission.READ_EXTERNAL_STORAGE, // read any images from disk to use as avatar Manifest.permission.WRITE_EXTERNAL_STORAGE // save to disk any picture taken by camera ), CODE_PERMISSION_EXTERNAL_STORAGE) } private fun createToolbarAnimator(toolbar: MementoToolbar): ToolbarBackgroundAnimator { return if (resources.getBoolean(R.bool.isLandscape)) { ToolbarBackgroundStubAnimator() } else { ToolbarBackgroundFadingAnimator.setupOn(toolbar) } } private val contactDetailsListener = object : ContactDetailsListener { override fun onContactCleared() { presenter.removeContact() } override fun onAddEventClicked(viewModel: AddEventContactEventViewModel) { val eventType = viewModel.eventType val initialDate = viewModel.date val dialog = EventDatePickerDialogFragment.newInstance(eventType, initialDate, shortDateLabelCreator) dialog.show(supportFragmentManager, "pick_event") } override fun onRemoveEventClicked(eventType: EventType) { presenter.removeEvent(eventType) } override fun onContactSelected(contact: Contact) { presenter.presentContact(contact) } override fun onNameModified(newName: String) { presenter.removeContact() presenter.presentName(newName) } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == CODE_PERMISSION_EXTERNAL_STORAGE && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { findViewById(android.R.id.content).post(Runnable { if (isFinishing) { return@Runnable } if (presenter.isDisplayingAvatar()) { BottomSheetPicturesDialog .includeClearImageOption() .show(supportFragmentManager, "picture_pick") } else { BottomSheetPicturesDialog .newInstance() .show(supportFragmentManager, "picture_pick") } }) } } var viewModel: PhotoPickerViewModel? = null override fun onImagePickerOptionSelected(viewModel: PhotoPickerViewModel) { this.viewModel = viewModel startActivityForResult(viewModel.intent, getRequestCodeFor(viewModel.intent)) } private fun getRequestCodeFor(intent: Intent): Int { val action = intent.action return when (action) { ImageIntentFactory.ACTION_IMAGE_CAPTURE -> CODE_TAKE_PICTURE ImageIntentFactory.ACTION_IMAGE_PICK -> CODE_PICK_A_FILE else -> throw IllegalArgumentException("Don't know how to handle $action") } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == CODE_TAKE_PICTURE && resultCode == Activity.RESULT_OK) { analytics.trackImageCaptured() startCropIntent(viewModel!!.absolutePath) } else if (requestCode == CODE_PICK_A_FILE && resultCode == Activity.RESULT_OK) { analytics.trackExistingImagePicked() val imageUri = BottomSheetPicturesDialog.getImagePickResultUri(data!!) startCropIntent(imageUri) } else if (requestCode == CODE_CROP_IMAGE) { val result = CropImage.getActivityResult(data) if (resultCode == Activity.RESULT_OK) { analytics.trackAvatarSelected() presenter.present(result.uri.toURI()) } else if (resultCode == Activity.RESULT_CANCELED && result != null) { tracker.track(result.error) } } } private fun startCropIntent(imageToCrop: URI) { val prefix = if (imageToCrop.scheme == null) { "file://" } else { "" } val size = queryCropSize(contentResolver) CropImage.activity(Uri.parse(prefix + imageToCrop.toString())) .setGuidelines(CropImageView.Guidelines.ON) .setAspectRatio(1, 1) .setRequestedSize(size, size) .start(this) } override fun onDatePicked(eventType: EventType, date: Date) { presenter.onEventDatePicked(eventType, date) } private fun queryCropSize(resolver: ContentResolver): Int { resolver.query( ContactsContract.DisplayPhoto.CONTENT_MAX_DIMENSIONS_URI, arrayOf(ContactsContract.DisplayPhoto.DISPLAY_MAX_DIM), null, null, null )?.use { cursor -> if (cursor.moveToFirst()) { return cursor.getInt(0) } } return MAX_RESOLUTION } override fun onClearAvatarSelected() { view.clearAvatar() } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> { if (presenter.isHoldingModifiedData) { promptToDiscardBeforeExiting() } else { cancelActivity() } true } else -> super.onOptionsItemSelected(item) } } private fun promptToDiscardBeforeExiting() { DiscardPromptDialog() .show(supportFragmentManager, "discard_prompt") } private fun finishActivitySuccessfully() { analytics.trackEventAddedSuccessfully() setResult(Activity.RESULT_OK) finish() } private fun cancelActivity() { analytics.trackAddEventsCancelled() setResult(Activity.RESULT_CANCELED) navigateUpToParent() } override fun finish() { super.finish() overridePendingTransition(R.anim.stay, R.anim.slide_out_from_below) } override fun onBackPressed() { if (presenter.isHoldingModifiedData) { promptToDiscardBeforeExiting() } else { super.onBackPressed() } } override fun onDestroy() { super.onDestroy() presenter.stopPresenting() } override fun onDiscardChangesSelected() { cancelActivity() } companion object { private const val CODE_TAKE_PICTURE = 404 private const val CODE_PICK_A_FILE = 405 private const val CODE_CROP_IMAGE = CropImage.CROP_IMAGE_ACTIVITY_REQUEST_CODE private const val CODE_PERMISSION_EXTERNAL_STORAGE = 406 private const val MAX_RESOLUTION = 720 fun buildIntent(context: Context): Intent { return Intent(context, AddEventActivity::class.java) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/AddEventModule.kt ================================================ package com.alexstyl.specialdates.addevent import android.content.ContentResolver import android.content.Context import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.Strings import com.alexstyl.specialdates.analytics.Analytics import com.alexstyl.specialdates.date.DateLabelCreator import com.alexstyl.specialdates.events.peopleevents.PeopleEventsProvider import com.alexstyl.specialdates.events.peopleevents.PeopleEventsUpdater import com.alexstyl.specialdates.events.peopleevents.ShortDateLabelCreator import com.alexstyl.specialdates.images.ImageDecoder import com.alexstyl.specialdates.images.ImageLoader import dagger.Module import dagger.Provides import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers @Module class AddEventModule { @Provides fun presenter(analytics: Analytics, contactOperations: ContactOperations, messageDisplayer: MessageDisplayer, operationsExecutorAndroid: ContactOperationsExecutor, strings: Strings, peopleEventsProvider: PeopleEventsProvider, peopleUpdater: PeopleEventsUpdater, factory: AddEventViewModelFactory) = AddEventsPresenter( analytics, contactOperations, messageDisplayer, operationsExecutorAndroid, strings, peopleEventsProvider, factory, peopleUpdater, Schedulers.io(), AndroidSchedulers.mainThread() ) @Provides fun factory(dateLabelCreator: DateLabelCreator, strings: Strings) = AddEventViewModelFactory( dateLabelCreator, strings, AndroidEventIcons) @Provides fun messageDisplayer(context: Context): MessageDisplayer = ToastDisplayer(context) @Provides fun accountsProvider(context: Context) = WriteableAccountsProvider.from(context) @Provides fun operations() = ContactOperations() @Provides fun operationsExectutor(contentResolver: ContentResolver, tracker: CrashAndErrorTracker, peopleEventsProvider: PeopleEventsProvider, accountsProvider: WriteableAccountsProvider, imageDecoder: ImageDecoder): ContactOperationsExecutor { return AndroidContactOperationsExecutor(contentResolver, tracker, ShortDateLabelCreator(), peopleEventsProvider, accountsProvider, imageDecoder) } @Provides fun filePathProvider(context: Context) = UriFilePathProvider(context) @Provides fun imageDecoder(imageLoader: ImageLoader): ImageDecoder { return ImageDecoder(imageLoader) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/AndroidAddEventView.kt ================================================ package com.alexstyl.specialdates.addevent import android.graphics.Bitmap import android.view.View import com.alexstyl.specialdates.Optional import com.alexstyl.specialdates.addevent.ui.AvatarPickerView import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.images.ImageLoadedConsumer import com.alexstyl.specialdates.images.ImageLoader import com.novoda.notils.meta.AndroidUtils import java.net.URI class AndroidAddEventView(private val avatarView: AvatarPickerView, private val eventsAdapter: ContactEventsAdapter, private val imageLoader: ImageLoader, private val toolbarAnimator: ToolbarBackgroundAnimator, private val saveButton: View) : AddEventView { override fun allowImagePick() { avatarView.isEnabled = true } override fun preventImagePick() { avatarView.isEnabled = false } override fun allowSave() { saveButton.isEnabled = true } override fun preventSave() { saveButton.isEnabled = false } private var currentImageLoaded = Optional.absent() override fun displayContact(contact: Contact) { display(contact.imagePath) AndroidUtils.requestHideKeyboard(avatarView.context, avatarView) } override fun display(viewModels: List) { eventsAdapter.display(viewModels) } override fun display(uri: URI) { imageLoader .load(uri) .withSize(avatarView.width, avatarView.height) .into(object : ImageLoadedConsumer { override fun onImageLoaded(loadedImage: Bitmap?) { avatarView.setImageBitmap(loadedImage) toolbarAnimator.fadeOut() } override fun onLoadingFailed() { clearAvatar() } }) } override fun clearAvatar() { avatarView.setImageBitmap(null) currentImageLoaded = Optional.absent() toolbarAnimator.fadeIn() } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/AndroidContactOperationsExecutor.kt ================================================ package com.alexstyl.specialdates.addevent import android.content.ContentProviderOperation import android.content.ContentResolver import android.content.OperationApplicationException import android.database.Cursor import android.os.RemoteException import android.provider.ContactsContract import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.addevent.operations.ContactOperation import com.alexstyl.specialdates.addevent.operations.InsertContact import com.alexstyl.specialdates.addevent.operations.UpdateContact import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.events.peopleevents.PeopleEventsProvider import com.alexstyl.specialdates.events.peopleevents.ShortDateLabelCreator import com.alexstyl.specialdates.images.ImageDecoder import com.novoda.notils.exception.DeveloperError import java.util.ArrayList class AndroidContactOperationsExecutor( private val contentResolver: ContentResolver, private val tracker: CrashAndErrorTracker, private val displayStringCreator: ShortDateLabelCreator, private val peopleEventsProvider: PeopleEventsProvider, private val accountsProvider: WriteableAccountsProvider, private val imageDecoder: ImageDecoder) : ContactOperationsExecutor { override fun execute(operations: List): Boolean { val operationsFactory = makeFactoryFor(operations[0]) try { val contentProviderOperations = ArrayList(operations.fold(emptyList(), { list, contactOperation -> list + operationsFactory.createOperationsFor(contactOperation) })) contentResolver.applyBatch(ContactsContract.AUTHORITY, contentProviderOperations) return true } catch (e: RemoteException) { tracker.track(e) } catch (e: OperationApplicationException) { tracker.track(e) } return false } private fun makeFactoryFor(contactOperation: ContactOperation): OperationsFactory { if (contactOperation is InsertContact) { return OperationsFactory.forNewContact(displayStringCreator, peopleEventsProvider, accountsProvider, imageDecoder) } else if (contactOperation is UpdateContact) { val rawContactID = rawContactID(contactOperation.contact) return OperationsFactory(rawContactID, displayStringCreator, peopleEventsProvider, accountsProvider, imageDecoder) } throw IllegalArgumentException("Cannot make factory for $contactOperation") } private fun rawContactID(contact: Contact): Int { val projection = arrayOf(ContactsContract.CommonDataKinds.Event.RAW_CONTACT_ID) val selection = ContactsContract.CommonDataKinds.Event.CONTACT_ID + " = ?" val selectionArgs = arrayOf(contact.contactID.toString()) val cursor = contentResolver.query(ContactsContract.Data.CONTENT_URI, projection, selection, selectionArgs, null) throwIfInvalid(cursor) try { if (cursor!!.moveToFirst()) { return cursor.getInt(0) } } finally { cursor!!.close() } return 0 } private fun throwIfInvalid(cursor: Cursor?) { if (cursor == null || cursor.isClosed) { throw DeveloperError("Cursor was invalid") } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/AndroidEventIcons.kt ================================================ package com.alexstyl.specialdates.addevent import com.alexstyl.specialdates.R import com.alexstyl.specialdates.events.database.EventTypeId import com.alexstyl.specialdates.events.peopleevents.EventType object AndroidEventIcons : EventIcons { override fun iconOf(eventType: EventType): Int = when (eventType.id) { EventTypeId.TYPE_BIRTHDAY -> R.drawable.ic_cake EventTypeId.TYPE_NAMEDAY -> R.drawable.ic_face EventTypeId.TYPE_ANNIVERSARY -> R.drawable.ic_anniversary EventTypeId.TYPE_OTHER -> R.drawable.ic_other EventTypeId.TYPE_CUSTOM -> R.drawable.ic_custom else -> { throw IllegalStateException("No icon for type $eventType") } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ContactDetailsListener.java ================================================ package com.alexstyl.specialdates.addevent; import com.alexstyl.specialdates.contact.Contact; import com.alexstyl.specialdates.events.peopleevents.EventType; interface ContactDetailsListener { void onAddEventClicked(AddEventContactEventViewModel viewModel); void onRemoveEventClicked(EventType eventType); void onContactSelected(Contact contact); void onNameModified(String newName); void onContactCleared(); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ContactEventViewHolder.java ================================================ package com.alexstyl.specialdates.addevent; import android.support.v7.widget.RecyclerView; import android.view.View; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; final class ContactEventViewHolder extends RecyclerView.ViewHolder { private final ImageView icon; private final TextView datePicker; private final ImageButton removeEvent; ContactEventViewHolder(View view, ImageView icon, TextView datePicker, ImageButton removeEvent) { super(view); this.icon = icon; this.datePicker = datePicker; this.removeEvent = removeEvent; } public void bind(final AddEventContactEventViewModel viewModel, final ContactDetailsListener contactDetailsListener) { icon.setImageResource(viewModel.getEventIconRes()); datePicker.setHint(viewModel.getHintText()); datePicker.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { contactDetailsListener.onAddEventClicked(viewModel); } }); boolean clearVisibility = viewModel.getClearVisibility(); removeEvent.setVisibility(clearVisibility ? View.VISIBLE : View.GONE); removeEvent.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { contactDetailsListener.onRemoveEventClicked(viewModel.getEventType()); } }); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ContactEventsAdapter.java ================================================ package com.alexstyl.specialdates.addevent; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.ViewHolder; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.TextView; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.addevent.ui.ContactSuggestionView; import com.novoda.notils.caster.Views; import java.util.ArrayList; import java.util.List; public final class ContactEventsAdapter extends RecyclerView.Adapter { private static final int HEADER_COUNT = 1; private static final int TYPE_CONTACT_SUGGESTION = 0; private static final int TYPE_EVENT = 1; private final List viewModels = new ArrayList<>(); private final ContactDetailsListener contactDetailsListener; ContactEventsAdapter(ContactDetailsListener contactDetailsListener) { this.contactDetailsListener = contactDetailsListener; } @Override public int getItemViewType(int position) { return position == 0 ? TYPE_CONTACT_SUGGESTION : TYPE_EVENT; } @Override public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { if (viewType == TYPE_CONTACT_SUGGESTION) { LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); View view = layoutInflater.inflate(R.layout.row_add_event_contact_suggestion, parent, false); ContactSuggestionView suggestionView = Views.findById(view, R.id.add_event_contact_autocomplete); return new ContactSuggestionViewHolder(suggestionView); } else if (viewType == TYPE_EVENT) { LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext()); View view = layoutInflater.inflate(R.layout.row_add_event_contact_event, parent, false); ImageView icon = Views.findById(view, R.id.add_event_event_icon); TextView datePicker = Views.findById(view, R.id.add_event_date_picker); ImageButton clear = Views.findById(view, R.id.add_event_remove_event); return new ContactEventViewHolder(view, icon, datePicker, clear); } else { throw new IllegalStateException("Received viewType " + viewType); } } @Override public void onBindViewHolder(ViewHolder holder, int position) { int viewType = getItemViewType(position); if (viewType == TYPE_CONTACT_SUGGESTION) { ((ContactSuggestionViewHolder) holder).bind(contactDetailsListener); } else if (viewType == TYPE_EVENT) { AddEventContactEventViewModel addEventContactEventViewModel = viewModels.get(position - HEADER_COUNT); ((ContactEventViewHolder) holder).bind(addEventContactEventViewModel, contactDetailsListener); } else { throw new IllegalStateException("Unable to bind view type " + viewType); } } @Override public int getItemCount() { return viewModels.size() + HEADER_COUNT; } void display(List viewModels) { this.viewModels.clear(); this.viewModels.addAll(viewModels); notifyItemRangeChanged(1, getItemCount() - 1); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ContactSuggestionViewHolder.java ================================================ package com.alexstyl.specialdates.addevent; import android.support.v7.widget.RecyclerView; import android.text.Editable; import com.alexstyl.specialdates.addevent.ui.ContactSuggestionView; import com.alexstyl.specialdates.contact.Contact; import com.novoda.notils.text.SimpleTextWatcher; final class ContactSuggestionViewHolder extends RecyclerView.ViewHolder { private ContactSuggestionView contactSuggestionView; ContactSuggestionViewHolder(ContactSuggestionView contactSuggestionView) { super(contactSuggestionView); this.contactSuggestionView = contactSuggestionView; } public void bind(final ContactDetailsListener listener) { contactSuggestionView.setOnContactSelectedListener(new ContactSuggestionView.OnContactSelectedListener() { @Override public void onContactSelected(Contact contact) { listener.onContactSelected(contact); } @Override public void onContactCleared() { listener.onContactCleared(); } }); contactSuggestionView.addTextChangedListener(new SimpleTextWatcher() { @Override public void afterTextChanged(Editable text) { listener.onNameModified(text.toString()); } }); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/DiscardPromptDialog.java ================================================ package com.alexstyl.specialdates.addevent; import android.app.Activity; import android.app.Dialog; import android.content.DialogInterface; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.v7.app.AlertDialog; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.ui.base.MementoDialog; import com.novoda.notils.caster.Classes; public class DiscardPromptDialog extends MementoDialog { interface Listener { void onDiscardChangesSelected(); } private Listener listener; @Override public void onAttach(Activity activity) { super.onAttach(activity); listener = Classes.from(activity); } @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { return new AlertDialog.Builder(getActivity()) .setTitle(R.string.add_event_discard_changes_title) .setPositiveButton(R.string.add_event_discard_changes_accept, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { listener.onDiscardChangesSelected(); } }) .setNegativeButton(android.R.string.no, null) .create(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/EventDatePickerDialogFragment.java ================================================ package com.alexstyl.specialdates.addevent; import android.app.Activity; import android.app.Dialog; import android.content.DialogInterface; import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v7.app.AlertDialog; import android.view.LayoutInflater; import android.view.View; import com.alexstyl.specialdates.Strings; import com.alexstyl.specialdates.AppComponent; import com.alexstyl.specialdates.Optional; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.addevent.ui.EventDatePicker; import com.alexstyl.specialdates.date.Date; import com.alexstyl.specialdates.date.DateParseException; import com.alexstyl.specialdates.events.database.EventTypeId; import com.alexstyl.specialdates.events.peopleevents.EventType; import com.alexstyl.specialdates.events.peopleevents.ShortDateLabelCreator; import com.alexstyl.specialdates.events.peopleevents.StandardEventType; import com.alexstyl.specialdates.ui.base.MementoDialog; import com.alexstyl.specialdates.date.DateParser; import com.novoda.notils.caster.Classes; import com.novoda.notils.caster.Views; import javax.inject.Inject; public class EventDatePickerDialogFragment extends MementoDialog { private static final String KEY_DATE = "key:date"; private static final String ARG_EVENT_TYPE_ID = "arg:event_type_id"; private OnEventDatePickedListener listener; private EventDatePicker datePicker; private Optional initialDate; @Inject Strings strings; @Inject DateParser dateParser; public static EventDatePickerDialogFragment newInstance(EventType eventType, Optional date, ShortDateLabelCreator shortDateLabelCreator) { EventDatePickerDialogFragment dialogFragment = new EventDatePickerDialogFragment(); Bundle args = new Bundle(2); args.putInt(ARG_EVENT_TYPE_ID, eventType.getId()); if (date.isPresent()) { String label = shortDateLabelCreator.createLabelWithYearPreferredFor(date.get()); args.putString(KEY_DATE, label); } dialogFragment.setArguments(args); return dialogFragment; } @Override public void onAttach(Activity activity) { super.onAttach(activity); this.listener = Classes.from(activity); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppComponent applicationModule = getApplication().getApplicationModule(); applicationModule.inject(this); initialDate = getDate(); } private Optional getDate() { Bundle arguments = getArguments(); if (arguments.containsKey(KEY_DATE)) { String birthday = arguments.getString(KEY_DATE); return parseFrom(birthday); } else { return Optional.Companion.absent(); } } private Optional parseFrom(String birthday) { try { Date parsedDate = dateParser.parse(birthday); return new Optional<>(parsedDate); } catch (DateParseException e) { e.printStackTrace(); return Optional.Companion.absent(); } } @Override public Dialog onCreateDialog(Bundle savedInstanceState) { View view = LayoutInflater.from(getThemedContext()).inflate(R.layout.dialog_birthday_picker, null, false); datePicker = Views.findById(view, R.id.dialog_birthday_picker); final EventType eventType = getEventType(); if (initialDate.isPresent()) { datePicker.setDisplayingDate(initialDate.get()); } return new AlertDialog.Builder(getActivity()) .setTitle(eventType.getEventName(strings)) .setView(view) .setPositiveButton(R.string.birthday_picker_dialog_positive, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { Date date = datePicker.getDisplayingDate(); listener.onDatePicked(eventType, date); } }) .create(); } public EventType getEventType() { @EventTypeId int eventTypeId = getArguments().getInt(ARG_EVENT_TYPE_ID); return StandardEventType.fromId(eventTypeId); } public interface OnEventDatePickedListener { void onDatePicked(EventType eventType, Date date); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ImageIntentFactory.java ================================================ package com.alexstyl.specialdates.addevent; import android.content.Intent; import android.net.Uri; import android.provider.MediaStore; public final class ImageIntentFactory { static final String ACTION_IMAGE_PICK = Intent.ACTION_PICK; static final String ACTION_IMAGE_CAPTURE = MediaStore.ACTION_IMAGE_CAPTURE; public Intent pickExistingImage() { Intent intent = new Intent(ACTION_IMAGE_PICK); intent.setType("image/*"); intent.addFlags( Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION ); return intent; } public Intent captureNewPhoto(Uri outputUri) { Intent takePictureIntent = new Intent(ACTION_IMAGE_CAPTURE); takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri); return takePictureIntent; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/OnCameraClickedListener.java ================================================ package com.alexstyl.specialdates.addevent; interface OnCameraClickedListener { void onPictureRetakenRequested(); void onNewPictureTakenRequested(); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/OperationsFactory.kt ================================================ package com.alexstyl.specialdates.addevent import android.content.ContentProviderOperation import android.provider.ContactsContract import android.provider.ContactsContract.CommonDataKinds.Event import android.provider.ContactsContract.CommonDataKinds.Photo import android.provider.ContactsContract.CommonDataKinds.StructuredName import android.provider.ContactsContract.Data import com.alexstyl.specialdates.addevent.operations.ContactOperation import com.alexstyl.specialdates.addevent.operations.InsertContact import com.alexstyl.specialdates.addevent.operations.InsertEvent import com.alexstyl.specialdates.addevent.operations.InsertImage import com.alexstyl.specialdates.addevent.operations.UpdateContact import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.date.ContactEvent import com.alexstyl.specialdates.date.Date import com.alexstyl.specialdates.date.TimePeriod import com.alexstyl.specialdates.events.database.EventTypeId import com.alexstyl.specialdates.events.peopleevents.EventType import com.alexstyl.specialdates.events.peopleevents.PeopleEventsProvider import com.alexstyl.specialdates.events.peopleevents.ShortDateLabelCreator import com.alexstyl.specialdates.events.peopleevents.StandardEventType import com.alexstyl.specialdates.images.ImageDecoder import java.net.URI class OperationsFactory(private val rawContactID: Int, private val displayStringCreator: ShortDateLabelCreator, private val peopleEventsProvider: PeopleEventsProvider, private val accountsProvider: WriteableAccountsProvider, private val imageDecoder: ImageDecoder) { fun createOperationsFor(contactOperation: ContactOperation): List { when (contactOperation) { is InsertContact -> return createContactIn(accountToStoreContact, contactOperation.contactName) is UpdateContact -> return updateExistingContact(contactOperation.contact) is InsertEvent -> return newInsertFor(contactOperation.eventType, contactOperation.date) is InsertImage -> return insertImageFor(contactOperation.imageUri) } throw IllegalArgumentException("Unable to create operation for $contactOperation") } private fun insertImageFor(imageUri: URI): List { val decodeFrom = imageDecoder.decodeFrom(imageUri) if (decodeFrom != null) { val builder = ContentProviderOperation.newInsert(Data.CONTENT_URI) .withValue(Data.MIMETYPE, Photo.CONTENT_ITEM_TYPE) .withValue(Photo.PHOTO, decodeFrom.bytes) addRawContactID(builder) return listOf(builder.build()) } else { return emptyList() } } private fun newInsertFor(eventType: EventType, date: Date): List { val builder = ContentProviderOperation .newInsert(Data.CONTENT_URI) .withValue(Data.MIMETYPE, Event.CONTENT_ITEM_TYPE) .withValue(Event.TYPE, androidIdOf(eventType)) .withValue(Event.START_DATE, displayStringCreator.createLabelWithYearPreferredFor(date)) addRawContactID(builder) return listOf(builder.build()) } private fun androidIdOf(eventType: EventType): Int = when (eventType.id) { EventTypeId.TYPE_BIRTHDAY -> Event.TYPE_BIRTHDAY EventTypeId.TYPE_ANNIVERSARY -> Event.TYPE_ANNIVERSARY EventTypeId.TYPE_CUSTOM -> Event.TYPE_CUSTOM EventTypeId.TYPE_OTHER -> Event.TYPE_OTHER else -> { throw IllegalStateException("There is no Android type of $eventType") } } private fun addRawContactID(builder: ContentProviderOperation.Builder) { if (rawContactID == NO_RAW_CONTACT_ID) { builder.withValueBackReference(Data.RAW_CONTACT_ID, rawContactID) } else { builder.withValue(Data.RAW_CONTACT_ID, rawContactID) } } private fun deleteEvents(contactEvents: List): ArrayList { val ops = ArrayList() for (contactEvent in contactEvents) { val eventId = contactEvent.deviceEventId.get() ops.add( ContentProviderOperation .newDelete(Data.CONTENT_URI) .withSelection(Event._ID + "= " + eventId, null) .build()) } return ops } private fun createContactIn(account: AccountData, contactName: String): ArrayList { val ops = ArrayList(2) ops.add( ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, account.accountName) .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, account.accountType) .build()) ops.add( ContentProviderOperation.newInsert(Data.CONTENT_URI) .withValueBackReference(Data.RAW_CONTACT_ID, rawContactID) .withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE) .withValue(StructuredName.DISPLAY_NAME, contactName) .build()) return ops } private val accountToStoreContact: AccountData get() { val availableAccounts = accountsProvider.availableAccounts return if (availableAccounts.size == 0) { AccountData.NO_ACCOUNT } else { availableAccounts[0] } } private fun updateExistingContact(contact: Contact): List { val contactEvents = getAllDeviceEventsFor(contact) return deleteEvents(contactEvents) } private fun getAllDeviceEventsFor(contact: Contact): List { val contactEvents = ArrayList() val contactEventsOnDate = peopleEventsProvider.fetchEventsBetween(TimePeriod.aYearFromNow()) for (contactEvent in contactEventsOnDate) { val (contactID) = contactEvent.contact if (contactID == contact.contactID && contactEvent.type !== StandardEventType.NAMEDAY) { contactEvents.add(contactEvent) } } return contactEvents } companion object { private const val NO_RAW_CONTACT_ID = 0 fun forNewContact(displayStringCreator: ShortDateLabelCreator, peopleEventsProvider: PeopleEventsProvider, accountsProvider: WriteableAccountsProvider, imageDecoder: ImageDecoder): OperationsFactory { return OperationsFactory(NO_RAW_CONTACT_ID, displayStringCreator, peopleEventsProvider, accountsProvider, imageDecoder) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ToastDisplayer.kt ================================================ package com.alexstyl.specialdates.addevent import android.content.Context import android.widget.Toast class ToastDisplayer(private val context: Context) : MessageDisplayer { override fun showMessage(string: String) { Toast.makeText(context, string, Toast.LENGTH_SHORT).show() } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ToolbarBackgroundAnimator.java ================================================ package com.alexstyl.specialdates.addevent; public interface ToolbarBackgroundAnimator { void fadeOut(); void fadeIn(); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ToolbarBackgroundFadingAnimator.java ================================================ package com.alexstyl.specialdates.addevent; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.TransitionDrawable; import android.support.v7.widget.Toolbar; final class ToolbarBackgroundFadingAnimator implements ToolbarBackgroundAnimator { private static final int FADING_DURATION = 400; private TransitionDrawable transitionDrawable; private boolean visible = true; static ToolbarBackgroundFadingAnimator setupOn(Toolbar toolbar) { ColorDrawable colorDrawable = new ColorDrawable(toolbar.getResources().getColor(android.R.color.transparent)); TransitionDrawable transitionDrawable = new TransitionDrawable(layersFrom(toolbar.getBackground(), colorDrawable)); transitionDrawable.setCrossFadeEnabled(true); toolbar.setBackground(transitionDrawable); return new ToolbarBackgroundFadingAnimator(transitionDrawable); } private ToolbarBackgroundFadingAnimator(TransitionDrawable transitionDrawable) { this.transitionDrawable = transitionDrawable; } @Override public void fadeOut() { if (visible) { transitionDrawable.startTransition(FADING_DURATION); visible = false; } } private static Drawable[] layersFrom(Drawable from, Drawable to) { Drawable[] layers = new Drawable[2]; layers[0] = from; layers[1] = to; return layers; } @Override public void fadeIn() { if (!visible) { transitionDrawable.reverseTransition(FADING_DURATION); visible = true; } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ToolbarBackgroundStubAnimator.java ================================================ package com.alexstyl.specialdates.addevent; final class ToolbarBackgroundStubAnimator implements ToolbarBackgroundAnimator { @Override public void fadeOut() { // does nothing } @Override public void fadeIn() { // does nothing } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/UriFilePathProvider.kt ================================================ package com.alexstyl.specialdates.addevent import android.content.Context import android.net.Uri import android.os.Environment import android.support.v4.content.FileProvider import com.alexstyl.specialdates.BuildConfig import java.io.File import java.io.IOException import java.text.SimpleDateFormat import java.util.Date class UriFilePathProvider(private val context: Context) { fun uriFor(file: File): Uri { return FileProvider.getUriForFile( context, BuildConfig.FILE_PROVIDER, file ) } @Throws(IOException::class) fun createImageFile(): File { val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date()) val imageFileName = "JPEG_" + timeStamp + "_" val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) val image = File.createTempFile( imageFileName, ".jpg", storageDir ) return image } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/WriteableAccountsProvider.java ================================================ package com.alexstyl.specialdates.addevent; import android.accounts.Account; import android.accounts.AccountManager; import android.accounts.AuthenticatorDescription; import android.content.Context; import android.content.pm.PackageManager; import android.graphics.drawable.Drawable; import java.util.ArrayList; public final class WriteableAccountsProvider { private static final String GOOGLE_ACCOUNT = "com.google"; private final AccountManager accountManager; private final PackageManager packageManager; public static WriteableAccountsProvider from(Context context) { AccountManager manager = (AccountManager) context.getSystemService(Context.ACCOUNT_SERVICE); PackageManager packageManager = context.getPackageManager(); return new WriteableAccountsProvider(manager, packageManager); } private WriteableAccountsProvider(AccountManager accountManager, PackageManager packageManager) { this.accountManager = accountManager; this.packageManager = packageManager; } ArrayList getAvailableAccounts() { ArrayList accounts = new ArrayList<>(); AuthenticatorDescription[] accountTypes = accountManager.getAuthenticatorTypes(); for (Account account : accountManager.getAccounts()) { String accountType = account.type; if (accountIsWritable(accountType)) { AuthenticatorDescription description = getAuthenticatorDescription(accountType, accountTypes); Drawable icon = packageManager.getDrawable(description.packageName, description.iconId, null); accounts.add(new AccountData(account.name, description.type, icon)); } } return accounts; } private static boolean accountIsWritable(String accountType) { return GOOGLE_ACCOUNT.equals(accountType); } private static AuthenticatorDescription getAuthenticatorDescription(String type, AuthenticatorDescription[] dictionary) { for (int i = 0; i < dictionary.length; i++) { if (dictionary[i].type.equals(type)) { return dictionary[i]; } } throw new RuntimeException("Unable to find matching authenticator"); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/bottomsheet/BottomSheetPicturesDialog.kt ================================================ package com.alexstyl.specialdates.addevent.bottomsheet import android.app.Activity import android.app.Dialog import android.content.Intent import android.os.Bundle import android.support.design.widget.BottomSheetDialog import android.support.v7.widget.GridLayoutManager import android.support.v7.widget.RecyclerView import android.view.LayoutInflater import com.alexstyl.specialdates.R import com.alexstyl.specialdates.addevent.ImageIntentFactory import com.alexstyl.specialdates.addevent.UriFilePathProvider import com.alexstyl.specialdates.ui.base.MementoDialog import com.alexstyl.specialdates.ui.widget.SpacesItemDecoration import com.novoda.notils.caster.Classes import com.novoda.notils.caster.Views import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import java.net.URI class BottomSheetPicturesDialog : MementoDialog() { private var parentListener: Listener? = null private lateinit var adapter: ImagePickerOptionsAdapter private lateinit var photoPickerViewModelFactory: PhotoPickerViewModelFactory private var disposable: Disposable? = null private val includeClear: Boolean get() = arguments != null && arguments!!.getBoolean(KEY_INCLUDE_CLEAR, false) private val internalListener = object : Listener { override fun onImagePickerOptionSelected(viewModel: PhotoPickerViewModel) { dismiss() parentListener?.onImagePickerOptionSelected(viewModel) } override fun onClearAvatarSelected() { dismiss() parentListener?.onClearAvatarSelected() } } override fun onAttach(activity: Activity) { super.onAttach(activity) parentListener = Classes.from(activity) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) photoPickerViewModelFactory = PhotoPickerViewModelFactory( UriFilePathProvider(activity!!), IntentResolver(activity!!.packageManager), ImageIntentFactory(), activity!!.packageManager ) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = BottomSheetDialog(activity!!) val layoutInflater = LayoutInflater.from(activity!!) val view = layoutInflater.inflate(R.layout.dialog_pick_image, null, false) val grid = Views.findById(view, R.id.pick_image_grid) val resources = resources val gridLayoutManager = GridLayoutManager(activity!!, resources.getInteger(R.integer.bottom_sheet_span_count)) grid.addItemDecoration(SpacesItemDecoration( resources.getDimensionPixelSize(R.dimen.add_event_image_option_vertical), gridLayoutManager.spanCount )) grid.layoutManager = gridLayoutManager adapter = if (includeClear) ImagePickerOptionsAdapter.createWithClear(internalListener) else ImagePickerOptionsAdapter.newInstance(internalListener) grid.adapter = adapter dialog.setContentView(view) return dialog } override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) disposable = Observable.fromCallable { photoPickerViewModelFactory.createViewModels() }.doOnError { it.printStackTrace() } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe { viewModels -> adapter.updateWith(viewModels) } } override fun onDestroy() { super.onDestroy() disposable?.dispose() } interface Listener { /** * Called when the user selects the option to select an picture as an avatar, via the [BottomSheetPicturesDialog] * */ fun onImagePickerOptionSelected(viewModel: PhotoPickerViewModel) /** * Called when the user selects the option to clear the existing avatar, via the [BottomSheetPicturesDialog] */ fun onClearAvatarSelected() } companion object { private const val KEY_INCLUDE_CLEAR = "key_include_clear" fun newInstance(): BottomSheetPicturesDialog { return BottomSheetPicturesDialog() } fun includeClearImageOption(): BottomSheetPicturesDialog { val args = Bundle(1) args.putBoolean(KEY_INCLUDE_CLEAR, true) val fragment = BottomSheetPicturesDialog() fragment.arguments = args return fragment } fun getImagePickResultUri(data: Intent): URI { return URI.create(data.data.toString()) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/bottomsheet/ClearImageViewHolder.java ================================================ package com.alexstyl.specialdates.addevent.bottomsheet; import android.support.v7.widget.RecyclerView; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import com.alexstyl.specialdates.R; final class ClearImageViewHolder extends RecyclerView.ViewHolder { private final ImageView imageView; private final TextView textView; ClearImageViewHolder(View itemView, ImageView imageView, TextView textView) { super(itemView); this.imageView = imageView; this.textView = textView; } void bind(final BottomSheetPicturesDialog.Listener listener) { imageView.setImageResource(R.drawable.ic_clear); textView.setText(R.string.add_event_remove_photo); itemView.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { listener.onClearAvatarSelected(); } }); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/bottomsheet/ImagePickerOptionViewHolder.kt ================================================ package com.alexstyl.specialdates.addevent.bottomsheet import android.support.v7.widget.RecyclerView import android.view.View import android.widget.ImageView import android.widget.TextView import com.alexstyl.specialdates.addevent.bottomsheet.BottomSheetPicturesDialog.Listener class ImagePickerOptionViewHolder(view: View, private val iconView: ImageView, private val labelView: TextView ) : RecyclerView.ViewHolder(view) { fun bind(viewModel: PhotoPickerViewModel, listener: Listener) { iconView.setImageDrawable(viewModel.activityIcon) labelView.text = viewModel.label itemView.setOnClickListener { listener.onImagePickerOptionSelected(viewModel) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/bottomsheet/ImagePickerOptionsAdapter.java ================================================ package com.alexstyl.specialdates.addevent.bottomsheet; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.addevent.bottomsheet.BottomSheetPicturesDialog.Listener; import java.util.ArrayList; import java.util.List; final class ImagePickerOptionsAdapter extends RecyclerView.Adapter { private static final int TYPE_CLEAR = 0; private static final int TYPE_OPTION = 1; private final List viewModels; private final Listener listener; private boolean includeClear; private final int includeClearCount; public static ImagePickerOptionsAdapter newInstance(Listener listener) { return new ImagePickerOptionsAdapter(listener, false); } static ImagePickerOptionsAdapter createWithClear(Listener listener) { return new ImagePickerOptionsAdapter(listener, true); } private ImagePickerOptionsAdapter(Listener listener, boolean includeClear) { this.includeClear = includeClear; this.viewModels = new ArrayList<>(); this.listener = listener; this.includeClearCount = includeClear ? 1 : 0; } @Override public int getItemViewType(int position) { if (includeClear && position == 0) { return TYPE_CLEAR; } return TYPE_OPTION; } @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.row_image_option, parent, false); ImageView iconView = view.findViewById(R.id.pick_image_activity_icon); TextView labelView = view.findViewById(R.id.pick_image_activity_label); if (viewType == TYPE_CLEAR) { return new ClearImageViewHolder(view, iconView, labelView); } else if (viewType == TYPE_OPTION) { return new ImagePickerOptionViewHolder(view, iconView, labelView); } else { throw new IllegalStateException("Illegal view type " + viewType); } } @Override public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { int itemViewType = getItemViewType(position); if (itemViewType == TYPE_CLEAR) { ((ClearImageViewHolder) holder).bind(listener); } else if (itemViewType == TYPE_OPTION) { PhotoPickerViewModel viewModel = viewModels.get(position - includeClearCount); ((ImagePickerOptionViewHolder) holder).bind(viewModel, listener); } else { throw new IllegalStateException("Illegal view type " + itemViewType); } } @Override public int getItemCount() { return viewModels.size() + includeClearCount; } void updateWith(List viewModels) { this.viewModels.clear(); this.viewModels.addAll(viewModels); notifyDataSetChanged(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/bottomsheet/IntentResolver.kt ================================================ package com.alexstyl.specialdates.addevent.bottomsheet import android.content.Intent import android.content.pm.PackageManager import java.net.URI class IntentResolver(private val packageManager: PackageManager) { internal fun createViewModelsFor(intent: Intent, absolutePath: String): List { return packageManager.queryIntentActivities(intent, 0) .map { resolveInfo -> val icon = resolveInfo.loadIcon(packageManager) val label = resolveInfo.loadLabel(packageManager).toString() val launchingIntent = Intent(intent) launchingIntent.setClassName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name) PhotoPickerViewModel(icon, label, launchingIntent, URI.create(absolutePath)) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/bottomsheet/PhotoPickerViewModel.kt ================================================ package com.alexstyl.specialdates.addevent.bottomsheet import android.content.Intent import android.graphics.drawable.Drawable import java.net.URI data class PhotoPickerViewModel( val activityIcon: Drawable, val label: String, val intent: Intent, val absolutePath: URI) ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/bottomsheet/PhotoPickerViewModelFactory.kt ================================================ package com.alexstyl.specialdates.addevent.bottomsheet import android.content.Intent import android.content.pm.PackageManager import android.provider.MediaStore import com.alexstyl.specialdates.addevent.ImageIntentFactory import com.alexstyl.specialdates.addevent.UriFilePathProvider class PhotoPickerViewModelFactory(private val uriFilePathProvider: UriFilePathProvider, private val intentResolver: IntentResolver, private val intentCreator: ImageIntentFactory, private val packageManager: PackageManager) { fun createViewModels(): List { return capturePhotoViewModels() + pickPhotoViewModels() } private fun capturePhotoViewModels(): List { val file = uriFilePathProvider.createImageFile() val outputUri = uriFilePathProvider.uriFor(file) val takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri) return if (takePictureIntent.resolveActivity(packageManager) != null) { intentResolver.createViewModelsFor(takePictureIntent, file.absolutePath) } else { emptyList() } } private fun pickPhotoViewModels(): List { val pickAnImage = intentCreator.pickExistingImage() return intentResolver.createViewModelsFor(pickAnImage, "") } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ui/AvatarPickerView.java ================================================ package com.alexstyl.specialdates.addevent.ui; import android.content.Context; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.View; import android.widget.ImageView; import android.widget.RelativeLayout; import com.alexstyl.specialdates.R; import com.nostra13.universalimageloader.core.assist.ViewScaleType; import com.nostra13.universalimageloader.core.imageaware.ImageAware; import com.novoda.notils.caster.Views; public final class AvatarPickerView extends RelativeLayout implements ImageAware { private ImageView imageView; private ImageView gradientTopView; private ImageView gradientBottomView; private View icon; public AvatarPickerView(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected void onFinishInflate() { super.onFinishInflate(); inflate(getContext(), R.layout.merge_avatar_picker_view, this); icon = findViewById(R.id.avatar_picker_icon); imageView = Views.findById(this, R.id.avatar_picker_image); gradientTopView = Views.findById(this, R.id.avatar_picker_gradient__top); gradientBottomView = Views.findById(this, R.id.avatar_picker_gradient__bottom); } @Override public ViewScaleType getScaleType() { return ViewScaleType.fromImageView(imageView); } @Override public View getWrappedView() { return imageView; } @Override public boolean isCollected() { return false; } @Override public boolean setImageDrawable(Drawable drawable) { if (drawable == null) { hideGradient(); } else { showGradient(); } imageView.setImageDrawable(drawable); return true; } private void hideGradient() { gradientTopView.setVisibility(GONE); gradientBottomView.setVisibility(GONE); } @Override public boolean setImageBitmap(Bitmap imageBitmap) { if (imageBitmap == null) { setImageDrawable(null); hideGradient(); } else { imageView.setImageBitmap(imageBitmap); showGradient(); } return true; } private void showGradient() { gradientTopView.setVisibility(VISIBLE); gradientBottomView.setVisibility(VISIBLE); } public boolean isDisplayingAvatar() { return imageView.getDrawable() != null; } @Override public void setEnabled(boolean enabled) { if (enabled) { icon.setVisibility(VISIBLE); } else { icon.setVisibility(GONE); } super.setEnabled(enabled); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ui/ContactSuggestionView.java ================================================ package com.alexstyl.specialdates.addevent.ui; import android.content.Context; import android.support.transition.TransitionManager; import android.text.TextWatcher; import android.util.AttributeSet; import android.view.View; import android.widget.AdapterView; import android.widget.AutoCompleteTextView; import android.widget.LinearLayout; import com.alexstyl.specialdates.AppComponent; import com.alexstyl.specialdates.MementoApplication; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.addevent.ContactsSearch; import com.alexstyl.specialdates.contact.Contact; import com.alexstyl.specialdates.contact.ContactsProvider; import com.alexstyl.specialdates.images.ImageLoader; import com.alexstyl.specialdates.search.NameMatcher; import com.novoda.notils.caster.Views; import com.novoda.notils.logger.simple.Log; import com.novoda.notils.meta.AndroidUtils; import javax.inject.Inject; public class ContactSuggestionView extends LinearLayout { private OnContactSelectedListener listener = OnContactSelectedListener.NO_CALLBACKS; private AutoCompleteTextView autoCompleteView; @Inject ImageLoader imageLoader; @Inject ContactsProvider contactsProvider; public ContactSuggestionView(Context context, AttributeSet attrs) { super(context, attrs); if (!isInEditMode()) { AppComponent applicationModule = ((MementoApplication) context.getApplicationContext()).getApplicationModule(); applicationModule.inject(this); } } @Override protected void onFinishInflate() { super.onFinishInflate(); super.setOrientation(HORIZONTAL); inflate(getContext(), R.layout.merge_contact_suggestion_view, this); autoCompleteView = Views.findById(this, R.id.contact_suggestion_autocomplete); if (isInEditMode()) { return; } final View clearContact = findViewById(R.id.add_event_remove_contact); clearContact.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { autoCompleteView.setText(""); clearContact.setVisibility(GONE); listener.onContactCleared(); autoCompleteView.setEnabled(true); autoCompleteView.getBackground().setAlpha(255); autoCompleteView.requestFocus(); } }); ContactsSearch contactsSearch = new ContactsSearch(contactsProvider, NameMatcher.INSTANCE); final ContactsAdapter adapter = new ContactsAdapter(contactsSearch, imageLoader); autoCompleteView.setAdapter(adapter); autoCompleteView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { listener.onContactSelected(adapter.getItem(position)); AndroidUtils.requestHideKeyboard(view.getContext(), view); TransitionManager.beginDelayedTransition(ContactSuggestionView.this); clearContact.setVisibility(VISIBLE); autoCompleteView.setEnabled(false); autoCompleteView.getBackground().setAlpha(0); } }); } public void addTextChangedListener(TextWatcher textWatcher) { autoCompleteView.addTextChangedListener(textWatcher); } public void setOnContactSelectedListener(OnContactSelectedListener listener) { this.listener = listener; } public interface OnContactSelectedListener { void onContactSelected(Contact contact); OnContactSelectedListener NO_CALLBACKS = new OnContactSelectedListener() { @Override public void onContactSelected(Contact contact) { Log.w("onContactSelected called with no callbacks"); } @Override public void onContactCleared() { Log.w("onContactCleared without a listener"); } }; void onContactCleared(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ui/ContactsAdapter.java ================================================ package com.alexstyl.specialdates.addevent.ui; import android.support.annotation.NonNull; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.Filter; import android.widget.Filterable; import android.widget.TextView; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.addevent.ContactsSearch; import com.alexstyl.specialdates.contact.Contact; import com.alexstyl.specialdates.images.ImageLoader; import com.alexstyl.specialdates.ui.widget.ColorImageView; import com.novoda.notils.caster.Views; import java.util.ArrayList; import java.util.List; class ContactsAdapter extends BaseAdapter implements Filterable { private final ImageLoader imageLoader; private final ArrayList contacts = new ArrayList<>(); private final Filter filter; ContactsAdapter(ContactsSearch contactsSearch, ImageLoader imageLoader) { this.imageLoader = imageLoader; this.filter = new DeviceContactsFilter(contactsSearch) { @Override public void onContactsFiltered(@NonNull List contacts) { setSuggestions(contacts); } }; } @Override public int getCount() { return contacts.size(); } @Override public Contact getItem(int position) { return contacts.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View view, ViewGroup parent) { ContactViewHolder vh; if (view == null) { view = LayoutInflater.from(parent.getContext()).inflate(R.layout.row_contact, parent, false); vh = new ContactViewHolder(); vh.contactName = Views.findById(view, R.id.display_name); vh.avatar = Views.findById(view, R.id.search_result_avatar); view.setTag(vh); } else { vh = (ContactViewHolder) view.getTag(); } Contact contact = getItem(position); String displayName = contact.toString(); vh.contactName.setText(displayName); vh.avatar.setCircleColorVariant((int) contact.getContactID()); vh.avatar.setLetter(displayName); imageLoader .load(contact.getImagePath()) .asCircle() .into(vh.avatar.getImageView()); return view; } private static class ContactViewHolder { private ColorImageView avatar; private TextView contactName; } private void setSuggestions(List contacts) { this.contacts.clear(); this.contacts.addAll(contacts); notifyDataSetChanged(); } @Override public Filter getFilter() { return filter; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ui/DeviceContactsFilter.kt ================================================ package com.alexstyl.specialdates.addevent.ui import android.widget.Filter import com.alexstyl.specialdates.addevent.ContactsSearch import com.alexstyl.specialdates.contact.Contact import java.util.ArrayList internal abstract class DeviceContactsFilter(private val contactsSearch: ContactsSearch) : Filter() { override fun performFiltering(constraint: CharSequence?): Filter.FilterResults { if (constraint == null || constraint.isEmpty()) { return emptyResults() } val searchQuery = constraint.trim { it <= ' ' }.toString() val contacts = contactsSearch.searchForContacts(searchQuery, LOAD_A_SINGLE_CONTACT) val filterResults = Filter.FilterResults() filterResults.values = contacts filterResults.count = contacts.size return filterResults } private fun emptyResults(): Filter.FilterResults { val filterResults = Filter.FilterResults() filterResults.values = ArrayList() filterResults.count = 0 return filterResults } override fun publishResults(constraint: CharSequence?, results: Filter.FilterResults) { onContactsFiltered(results.values as List) } abstract fun onContactsFiltered(contacts: List) companion object { private const val LOAD_A_SINGLE_CONTACT = 1 } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/addevent/ui/EventDatePicker.java ================================================ package com.alexstyl.specialdates.addevent.ui; import android.content.Context; import android.support.transition.TransitionManager; import android.util.AttributeSet; import android.view.View; import android.widget.CheckedTextView; import android.widget.LinearLayout; import android.widget.NumberPicker; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.date.Date; import com.alexstyl.specialdates.date.MonthInt; import com.alexstyl.specialdates.upcoming.MonthLabels; import com.novoda.notils.caster.Views; import java.util.Locale; public class EventDatePicker extends LinearLayout { private static final int FIRST_DAY_OF_MONTH = 1; private final MonthLabels labels; private final NumberPicker dayPicker; private final NumberPicker monthPicker; private final NumberPicker yearPicker; private final CheckedTextView includesYearCheckbox; private final Date today; private static final int FIRST_MONTH = 1; private static final int LAST_MONTH = 12; private static final int FIRST_YEAR = 1900; public EventDatePicker(Context context, AttributeSet attrs) { super(context, attrs); super.setOrientation(HORIZONTAL); labels = MonthLabels.forLocale(Locale.getDefault()); today = Date.Companion.today(); inflate(getContext(), R.layout.merge_birthday_picker, this); dayPicker = Views.findById(this, R.id.day_picker); setupDayPicker(); monthPicker = Views.findById(this, R.id.month_picker); setupMonthPicker(); yearPicker = Views.findById(this, R.id.year_picker); setupYearPicker(); includesYearCheckbox = Views.findById(this, R.id.include_year_checkbox); includesYearCheckbox.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { boolean checked = includesYearCheckbox.isChecked(); includesYearCheckbox.setChecked(!checked); if (includesYearCheckbox.isChecked()) { showYearPicker(); } else { hideYearPicker(); } updateMaximumDaysInCurrentMonth(); } private void hideYearPicker() { TransitionManager.beginDelayedTransition(EventDatePicker.this); yearPicker.setVisibility(GONE); } private void showYearPicker() { TransitionManager.beginDelayedTransition(EventDatePicker.this); yearPicker.setVisibility(VISIBLE); } }); } private void setupDayPicker() { dayPicker.setMinValue(FIRST_DAY_OF_MONTH); dayPicker.setMaxValue(today.getDaysInCurrentMonth()); dayPicker.setValue(today.getDayOfMonth()); } private void setupMonthPicker() { monthPicker.setMinValue(FIRST_MONTH); monthPicker.setMaxValue(LAST_MONTH); monthPicker.setDisplayedValues(labels.getMonthsOfYear()); monthPicker.setValue(today.getMonth()); monthPicker.setOnValueChangedListener(dateValidator); } private void setupYearPicker() { yearPicker.setMinValue(FIRST_YEAR); yearPicker.setMaxValue(currentYear()); yearPicker.setValue(currentYear()); yearPicker.setOnValueChangedListener(dateValidator); } private Integer currentYear() { return today.getYear(); } public void setDisplayingDate(Date dateToDisplay) { if (dateToDisplay.hasYear()) { dayPicker.setValue(dateToDisplay.getDayOfMonth()); monthPicker.setValue(dateToDisplay.getMonth()); yearPicker.setValue(dateToDisplay.getYear()); yearPicker.setVisibility(VISIBLE); includesYearCheckbox.setChecked(true); } else { dayPicker.setValue(dateToDisplay.getDayOfMonth()); monthPicker.setValue(dateToDisplay.getMonth()); yearPicker.setValue(currentYear()); yearPicker.setVisibility(GONE); includesYearCheckbox.setChecked(false); } } public Date getDisplayingDate() { int dayOfMonth = getDayOfMonth(); int month = getMonth(); if (isDisplayingYear()) { int year = getYear(); return Date.Companion.on(dayOfMonth, month, year); } else { return Date.Companion.on(dayOfMonth, month); } } private boolean isDisplayingYear() { return includesYearCheckbox.isChecked(); } private int getDayOfMonth() { return dayPicker.getValue(); } @MonthInt private int getMonth() { @MonthInt int value = monthPicker.getValue(); return value; } private int getYear() { return yearPicker.getValue(); } private final NumberPicker.OnValueChangeListener dateValidator = new NumberPicker.OnValueChangeListener() { @Override public void onValueChange(NumberPicker picker, int oldVal, int newVal) { updateMaximumDaysInCurrentMonth(); } }; private void updateMaximumDaysInCurrentMonth() { int maxDays; if (isDisplayingYear()){ maxDays = Date.Companion.on(FIRST_DAY_OF_MONTH, getMonth(), getYear()).getDaysInCurrentMonth(); } else { maxDays = Date.Companion.on(FIRST_DAY_OF_MONTH, getMonth()).getDaysInCurrentMonth(); } dayPicker.setMaxValue(maxDays); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/analytics/Action.java ================================================ package com.alexstyl.specialdates.analytics; public enum Action { ADD_BIRTHDAY("add_bday"), DAILY_REMINDER("reminder"), DONATION("donate"), INTERACT_CONTACT("contact"), SELECT_THEME("theme"), SELECT_DATE("select_date"), COMPLICATION("complication: contacts events"); private final String name; Action(String name) { this.name = name; } public String getName() { return name; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/analytics/ActionWithParameters.java ================================================ package com.alexstyl.specialdates.analytics; public final class ActionWithParameters { private final Action actionName; private final String label; private final String value; public ActionWithParameters(Action actionName, String label, String value) { this.actionName = actionName; this.label = label; this.value = value; } public String getName() { return actionName.getName(); } public String getLabel() { return label; } public String getValue() { return value; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/analytics/AnalyticsModule.kt ================================================ package com.alexstyl.specialdates.analytics import android.content.Context import com.alexstyl.specialdates.common.BuildConfig import com.google.firebase.analytics.FirebaseAnalytics import com.mixpanel.android.mpmetrics.MixpanelAPI import javax.inject.Singleton import dagger.Module import dagger.Provides @Module @Singleton class AnalyticsModule { @Provides @Singleton internal fun providesAnalytics(context: Context): Analytics { return CompositeAnalytics(buildMixPanel(context), buildFirebase(context)) } private fun buildFirebase(context: Context) = FirebaseAnalyticsImpl(FirebaseAnalytics.getInstance(context)) private fun buildMixPanel(context: Context): MixPanel { val projectToken = BuildConfig.MIXPANEL_TOKEN val mixpanel = MixpanelAPI.getInstance(context, projectToken) val analytics = MixPanel(mixpanel) return analytics } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/analytics/CompositeAnalytics.kt ================================================ package com.alexstyl.specialdates.analytics import com.alexstyl.specialdates.TimeOfDay import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.donate.Donation import com.alexstyl.specialdates.events.peopleevents.EventType class CompositeAnalytics(private vararg val analytics: Analytics) : Analytics { override fun trackThemeSelected(string: String) { analytics.forEach { it.trackThemeSelected(string) } } override fun trackScreen(screen: Screen) { analytics.forEach { it.trackScreen(screen) } } override fun trackAddEventsCancelled() { analytics.forEach { it.trackAddEventsCancelled() } } override fun trackEventAddedSuccessfully() { analytics.forEach { it.trackEventAddedSuccessfully() } } override fun trackContactSelected() { analytics.forEach { it.trackContactSelected() } } override fun trackEventDatePicked(eventType: EventType) { analytics.forEach { it.trackEventDatePicked(eventType) } } override fun trackEventRemoved(eventType: EventType) { analytics.forEach { it.trackEventRemoved(eventType) } } override fun trackImageCaptured() { analytics.forEach { it.trackImageCaptured() } } override fun trackExistingImagePicked() { analytics.forEach { it.trackExistingImagePicked() } } override fun trackAvatarSelected() { analytics.forEach { it.trackAvatarSelected() } } override fun trackContactUpdated() { analytics.forEach { it.trackContactUpdated() } } override fun trackContactCreated() { analytics.forEach { it.trackContactCreated() } } override fun trackDailyReminderEnabled() { analytics.forEach { it.trackDailyReminderEnabled() } } override fun trackDailyReminderDisabled() { analytics.forEach { it.trackDailyReminderDisabled() } } override fun trackDailyReminderTimeUpdated(timeOfDay: TimeOfDay) { analytics.forEach { it.trackDailyReminderTimeUpdated(timeOfDay) } } override fun trackWidgetAdded(widget: Widget) { analytics.forEach { it.trackWidgetAdded(widget) } } override fun trackWidgetRemoved(widget: Widget) { analytics.forEach { it.trackWidgetRemoved(widget) } } override fun trackDonationStarted(donation: Donation) { analytics.forEach { it.trackDonationStarted(donation) } } override fun trackAppInviteRequested() { analytics.forEach { it.trackAppInviteRequested() } } override fun trackDonationRestored() { analytics.forEach { it.trackDonationRestored() } } override fun trackDonationPlaced(donation: Donation) { analytics.forEach { it.trackDonationPlaced(donation) } } override fun trackFacebookLoggedIn() { analytics.forEach { it.trackFacebookLoggedIn() } } override fun trackOnAvatarBounce() { analytics.forEach { it.trackOnAvatarBounce() } } override fun trackFacebookLoggedOut() { analytics.forEach { it.trackFacebookLoggedOut() } } override fun trackVisitGithub() { analytics.forEach { it.trackVisitGithub() } } override fun trackContactDetailsViewed(contact: Contact) { analytics.forEach { it.trackContactDetailsViewed(contact) } } override fun trackNamedaysScreen() { analytics.forEach { it.trackNamedaysScreen() } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/analytics/FirebaseAnalyticsImpl.kt ================================================ package com.alexstyl.specialdates.analytics import android.os.Bundle import android.support.annotation.NonNull import android.support.annotation.Size import com.alexstyl.specialdates.TimeOfDay import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.donate.Donation import com.alexstyl.specialdates.events.peopleevents.EventType import com.google.firebase.analytics.FirebaseAnalytics class FirebaseAnalyticsImpl(private val firebase: FirebaseAnalytics) : Analytics { override fun trackThemeSelected(string: String) { firebase.logEvent(Action.SELECT_THEME.name, Bundle().apply { putString("theme name", string) }) } override fun trackScreen(screen: Screen) { val bundle = Bundle() bundle.putString("screen_name", screen.screenName()) firebase.logEvent("screen_view", bundle) } override fun trackAddEventsCancelled() { firebase.logEvent("add_events_cancelled") } override fun trackEventAddedSuccessfully() { firebase.logEvent("add_events_success") } override fun trackContactSelected() { firebase.logEvent("contact_selected") } override fun trackEventDatePicked(eventType: EventType) { val properties = createPropertyFor(eventType) firebase.logEvent("event_date_picked", properties) } override fun trackEventRemoved(eventType: EventType) { val properties = createPropertyFor(eventType) firebase.logEvent("event_removed", properties) } override fun trackImageCaptured() { firebase.logEvent("image_captured") } override fun trackExistingImagePicked() { firebase.logEvent("existing_image_picked") } override fun trackAvatarSelected() { firebase.logEvent("avatar_selected") } override fun trackContactUpdated() { firebase.logEvent("contact_updated") } override fun trackContactCreated() { firebase.logEvent("contact_created") } override fun trackDailyReminderEnabled() { firebase.logEvent("daily_reminder_enabled") } override fun trackDailyReminderDisabled() { firebase.logEvent("daily_reminder_disabled") } override fun trackDailyReminderTimeUpdated(timeOfDay: TimeOfDay) { val properties = createPropertyFor(timeOfDay) firebase.logEvent("daily_reminder_time_updated", properties) } override fun trackWidgetAdded(widget: Widget) { firebase.logEvent("widget_added", widgetNameOf(widget)) } private fun widgetNameOf(widget: Widget): Bundle { return Bundle().apply { putString("widget_name", widget.widgetName) } } override fun trackWidgetRemoved(widget: Widget) { firebase.logEvent("widget_removed", widgetNameOf(widget)) } override fun trackDonationStarted(donation: Donation) { val properties = Bundle().apply { putString(FirebaseAnalytics.Param.ITEM_ID, donation.identifier) putString(FirebaseAnalytics.Param.PRICE, donation.amount) } firebase.logEvent("donation_started", properties) } override fun trackAppInviteRequested() { firebase.logEvent("app_invite_requested") } override fun trackDonationRestored() { firebase.logEvent("donation_restored") } override fun trackDonationPlaced(donation: Donation) { val properties = Bundle().apply { putString(FirebaseAnalytics.Param.ITEM_ID, donation.identifier) putString(FirebaseAnalytics.Param.PRICE, donation.amount) } firebase.logEvent(FirebaseAnalytics.Event.ECOMMERCE_PURCHASE, properties) } override fun trackFacebookLoggedIn() { firebase.logEvent("facebook_log_in") } override fun trackOnAvatarBounce() { firebase.logEvent("avatar_bounce") } override fun trackFacebookLoggedOut() { firebase.logEvent("facebook_log_out") } override fun trackVisitGithub() { firebase.logEvent("visit_github") } override fun trackContactDetailsViewed(contact: Contact) { firebase.logEvent("view_contact_details") } override fun trackNamedaysScreen() { firebase.logEvent("namedays_screen") } private fun createPropertyFor(eventType: EventType): Bundle { val properties = Bundle() properties.putInt("event_type", eventType.id) return properties } private fun createPropertyFor(timeOfDay: TimeOfDay): Bundle { return Bundle().apply { putString("time", timeOfDay.toString()) } } private fun FirebaseAnalytics.logEvent(@NonNull @Size(min = 1L, max = 40L) eventName: String) { return logEvent(eventName, Bundle.EMPTY) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/analytics/MixPanel.kt ================================================ package com.alexstyl.specialdates.analytics import com.alexstyl.specialdates.TimeOfDay import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.donate.Donation import com.alexstyl.specialdates.events.peopleevents.EventType import com.mixpanel.android.mpmetrics.MixpanelAPI import org.json.JSONException import org.json.JSONObject class MixPanel(private val mixpanel: MixpanelAPI) : Analytics { override fun trackThemeSelected(string: String) { mixpanel.track(Action.SELECT_THEME.name, JSONObject().apply { put("theme name", string) }) } override fun trackScreen(screen: Screen) { mixpanel.track("ScreenView: " + screen.screenName()) } override fun trackAddEventsCancelled() { mixpanel.track("add events cancelled") } override fun trackEventAddedSuccessfully() { mixpanel.track("add events success") } override fun trackContactSelected() { mixpanel.track("contact selected") } override fun trackEventDatePicked(eventType: EventType) { val properties = createPropertyFor(eventType) mixpanel.track("event date picked ", properties) } override fun trackEventRemoved(eventType: EventType) { val properties = createPropertyFor(eventType) mixpanel.track("event removed", properties) } override fun trackImageCaptured() { mixpanel.track("image captured") } override fun trackExistingImagePicked() { mixpanel.track("existing image picked") } override fun trackAvatarSelected() { mixpanel.track("avatar selected") } override fun trackContactUpdated() { mixpanel.track("contact updated") } override fun trackContactCreated() { mixpanel.track("contact created") } override fun trackDailyReminderEnabled() { mixpanel.track("daily reminder enabled") } override fun trackDailyReminderDisabled() { mixpanel.track("daily reminder disabled") } override fun trackDailyReminderTimeUpdated(timeOfDay: TimeOfDay) { val properties = createPropertyFor(timeOfDay) mixpanel.track("daily reminder time updated", properties) } override fun trackWidgetAdded(widget: Widget) { mixpanel.track("widget_added", widgetNameOf(widget)) } private fun widgetNameOf(widget: Widget): JSONObject { val properties = JSONObject() try { properties.put("widget_name", widget.widgetName) return properties } catch (e: JSONException) { e.printStackTrace() } return properties } override fun trackWidgetRemoved(widget: Widget) { mixpanel.track("widget_removed", widgetNameOf(widget)) } override fun trackDonationStarted(donation: Donation) { val properties = JSONObject() try { properties.put("id", donation.identifier) properties.put("amount", donation.amount) } catch (e: JSONException) { e.printStackTrace() } mixpanel.track("donation started", properties) } override fun trackAppInviteRequested() { mixpanel.track("app_invite_requested") } override fun trackDonationRestored() { mixpanel.track("donation_restored") } override fun trackDonationPlaced(donation: Donation) { val properties = JSONObject() try { properties.put("amount", donation.amount) properties.put("identifier", donation.identifier) } catch (e: JSONException) { e.printStackTrace() } mixpanel.track("donation_placed", properties) } override fun trackFacebookLoggedIn() { mixpanel.track("facebook_log_in") } override fun trackOnAvatarBounce() { mixpanel.track("avatar bounce") } override fun trackFacebookLoggedOut() { mixpanel.track("facebook_log_out") } override fun trackVisitGithub() { mixpanel.track("visit_github") } override fun trackContactDetailsViewed(contact: Contact) { mixpanel.track("view_contact_details") } override fun trackNamedaysScreen() { mixpanel.track("namedays_screen") } private fun createPropertyFor(eventType: EventType): JSONObject { val properties = JSONObject() try { properties.put("event type", eventType.id) } catch (e: JSONException) { e.printStackTrace() } return properties } private fun createPropertyFor(timeOfDay: TimeOfDay): JSONObject { val properties = JSONObject() try { properties.put("time", timeOfDay.toString()) } catch (e: JSONException) { e.printStackTrace() } return properties } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/contact/AndroidContactFactory.kt ================================================ package com.alexstyl.specialdates.contact import android.content.ContentResolver import android.content.ContentUris import android.database.Cursor import android.provider.ContactsContract import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.contact.AndroidContactsQuery.SORT_ORDER import com.alexstyl.specialdates.contact.ContactSource.SOURCE_DEVICE import java.net.URI class AndroidContactFactory(private val resolver: ContentResolver, private val tracker: CrashAndErrorTracker) { fun getAllContacts(): Contacts { val cursor: Cursor? try { cursor = resolver.query( AndroidContactsQuery.CONTENT_URI, AndroidContactsQuery.PROJECTION, WHERE, null, AndroidContactsQuery.SORT_ORDER ) } catch (e: Exception) { tracker.track(e) return Contacts(SOURCE_DEVICE, emptyList()) } return cursor.use { return@use Contacts(SOURCE_DEVICE, List(it.count, { index -> it.moveToPosition(index) createContactFrom(it) })) } } @Throws(ContactNotFoundException::class) fun createContactWithId(contactID: Long): Contact { val cursor = queryContactsWithContactId(contactID) if (isInvalid(cursor)) { throw RuntimeException("Cursor was invalid") } cursor.use { if (it.moveToFirst()) { return createContactFrom(it) } } throw ContactNotFoundException(contactID) } fun queryContacts(ids: List): Contacts { val cursor = queryContactsWithContactId(ids) return cursor.use { return@use Contacts(SOURCE_DEVICE, List(it.count) { index -> it.moveToPosition(index) createContactFrom(it) }) } } private fun queryContactsWithContactId(ids: List): Cursor { return resolver.query( AndroidContactsQuery.CONTENT_URI, AndroidContactsQuery.PROJECTION, "${AndroidContactsQuery._ID} IN (${ids.joinToString(",")})", null, SORT_ORDER ) } private fun createContactFrom(cursor: Cursor): Contact { val contactID = getContactIdFrom(cursor) val displayName = getDisplayNameFrom(cursor) val imagePath = URI.create(ContentUris.withAppendedId(AndroidContactsQuery.CONTENT_URI, contactID).toString()) return Contact(contactID, displayName, imagePath, SOURCE_DEVICE) } private fun queryContactsWithContactId(contactID: Long): Cursor { return resolver.query( AndroidContactsQuery.CONTENT_URI, AndroidContactsQuery.PROJECTION, SELECTION_CONTACT_WITH_ID, makeSelectionArgumentsFor(contactID), SORT_ORDER + " LIMIT 1" ) } private fun makeSelectionArgumentsFor(contactID: Long): Array = arrayOf(contactID.toString()) companion object { private val WHERE = ContactsContract.Data.IN_VISIBLE_GROUP + "=1" private val SELECTION_CONTACT_WITH_ID = AndroidContactsQuery._ID + " = ?" private fun isInvalid(cursor: Cursor?): Boolean = cursor == null || cursor.isClosed private fun getContactIdFrom(cursor: Cursor): Long = cursor.getLong(AndroidContactsQuery.CONTACT_ID) private fun getDisplayNameFrom(cursor: Cursor): DisplayName = DisplayName.from(cursor.getString(AndroidContactsQuery.DISPLAY_NAME)) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/contact/AndroidContactsProviderSource.kt ================================================ package com.alexstyl.specialdates.contact internal class AndroidContactsProviderSource(private val cache: ContactCache, private val factory: AndroidContactFactory) : ContactsProviderSource { @Throws(ContactNotFoundException::class) override fun getOrCreateContact(contactID: Long): Contact { var deviceContact = cache.getContact(contactID) if (deviceContact == null) { deviceContact = factory.createContactWithId(contactID) cache.addContact(deviceContact) } return deviceContact } override val allContacts: Contacts get() { val allContacts = factory.getAllContacts() cache.evictAll() cache.addContacts(allContacts) return allContacts } override fun queryContacts(contactIds: List): Contacts { val contacts = factory.queryContacts(contactIds) cache.addContacts(contacts) return contacts } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/contact/AndroidContactsQuery.java ================================================ package com.alexstyl.specialdates.contact; import android.net.Uri; import android.provider.ContactsContract; import android.provider.ContactsContract.Contacts; final class AndroidContactsQuery { public static final Uri CONTENT_URI = ContactsContract.Contacts.CONTENT_URI; static final String[] PROJECTION = { Contacts._ID, Contacts.DISPLAY_NAME_PRIMARY, }; static final String SORT_ORDER = Contacts._ID; public static final int CONTACT_ID = 0; static final int DISPLAY_NAME = 1; public static final String _ID = Contacts._ID; private AndroidContactsQuery() { // hide this } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/contact/ContactIntentExtractor.kt ================================================ package com.alexstyl.specialdates.contact import android.content.Intent import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.MementoConstants import com.alexstyl.specialdates.Optional class ContactIntentExtractor(val tracker: CrashAndErrorTracker, val contactsProvider: ContactsProvider) { companion object { val EXTRA_CONTACT_SOURCE = "${MementoConstants.PACKAGE}.extra:source" val EXTRA_CONTACT_ID = "${MementoConstants.PACKAGE}.extra:contactId" } fun getContactExtra(intent: Intent): Optional { val contactID = intent.getLongExtra(EXTRA_CONTACT_ID, -1) if (contactID == -1L) { return Optional.absent() } @ContactSource val contactSource = intent.getIntExtra(EXTRA_CONTACT_SOURCE, -1) return if (contactSource == -1) { return Optional.absent() } else contactFor(contactID, contactSource) } private fun contactFor(contactID: Long, contactSource: Int): Optional { return try { Optional(contactsProvider.getContact(contactID, contactSource)) } catch (e: ContactNotFoundException) { tracker.track(e) Optional.absent() } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/contact/ContactsModule.kt ================================================ package com.alexstyl.specialdates.contact import android.content.ContentResolver import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.contact.ContactSource.SOURCE_DEVICE import com.alexstyl.specialdates.contact.ContactSource.SOURCE_FACEBOOK import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper import dagger.Module import dagger.Provides import javax.inject.Singleton @Module class ContactsModule { @Provides @Singleton internal fun provider(contentResolver: ContentResolver, eventSQLiteOpenHelper: EventSQLiteOpenHelper, tracker: CrashAndErrorTracker): ContactsProvider { return ContactsProvider(mapOf( Pair(SOURCE_DEVICE, buildAndroidSource(tracker, contentResolver)), Pair(SOURCE_FACEBOOK, buildFacebookSource(eventSQLiteOpenHelper)) )) } companion object { private const val CACHE_SIZE = 1024 private fun buildAndroidSource(tracker: CrashAndErrorTracker, contentResolver: ContentResolver): ContactsProviderSource { val factory = AndroidContactFactory(contentResolver, tracker) val contactCache = ContactCache(CACHE_SIZE) return AndroidContactsProviderSource(contactCache, factory) } private fun buildFacebookSource(eventSQLHelper: EventSQLiteOpenHelper): ContactsProviderSource { val contactCache = ContactCache(CACHE_SIZE) return FacebookContactsSource(eventSQLHelper, contactCache) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/contact/EmptyContactSource.kt ================================================ package com.alexstyl.specialdates.contact class EmptyContactSource(val source: Int) : ContactsProviderSource { override val allContacts: Contacts get() = Contacts(source, emptyList()) @Throws(ContactNotFoundException::class) override fun getOrCreateContact(contactID: Long): Contact { throw ContactNotFoundException(contactID) } override fun queryContacts(contactIds: List): Contacts = Contacts(source, emptyList()) } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/contact/FacebookContactsSource.kt ================================================ package com.alexstyl.specialdates.contact import android.database.Cursor import com.alexstyl.specialdates.contact.ContactSource.SOURCE_FACEBOOK import com.alexstyl.specialdates.events.database.DatabaseContract.AnnualEventsContract import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper import com.alexstyl.specialdates.facebook.FacebookImagePath internal class FacebookContactsSource(private val eventSQLHelper: EventSQLiteOpenHelper, private val cache: ContactCache) : ContactsProviderSource { @Throws(ContactNotFoundException::class) override fun getOrCreateContact(contactID: Long): Contact { var contact: Contact? = cache.getContact(contactID) if (contact == null) { contact = queryContactWith(contactID) } return contact } @Throws(ContactNotFoundException::class) private fun queryContactWith(contactID: Long): Contact { val readableDatabase = eventSQLHelper.readableDatabase val cursor = readableDatabase.query( AnnualEventsContract.TABLE_NAME, null, "$IS_A_FACEBOOK_CONTACT AND ${AnnualEventsContract.CONTACT_ID} == $contactID", null, null, null, null ) return cursor.use { if (!it.moveToFirst()) { throw ContactNotFoundException(contactID) } return@use createContactFrom(cursor) } } override fun queryContacts(contactIds: List): Contacts { val readableDatabase = eventSQLHelper.readableDatabase val cursor = readableDatabase.query( AnnualEventsContract.TABLE_NAME, null, "$IS_A_FACEBOOK_CONTACT AND ${AnnualEventsContract.CONTACT_ID} IN (${List(contactIds.size, { "?" }).joinToString(",")})", contactIds.map { it.toString() }.toTypedArray(), null, null, null ) return cursor.use { val contacts = Contacts(SOURCE_FACEBOOK, List(it.count, { index -> it.moveToPosition(index) createContactFrom(it) })) cache.addContacts(contacts) return@use contacts } } override val allContacts: Contacts get() { return queryAllContacts().apply { cache.evictAll() cache.addContacts(this) } } private fun queryAllContacts(): Contacts { val db = eventSQLHelper.readableDatabase val cursor = db.rawQuery( "SELECT * FROM ${AnnualEventsContract.TABLE_NAME}" + " WHERE ${AnnualEventsContract.SOURCE} == ? " + " GROUP BY ${AnnualEventsContract.CONTACT_ID}", arrayOf(SOURCE_FACEBOOK.toString())) return cursor.use { return@use Contacts(SOURCE_FACEBOOK, List(it.count, { index -> it.moveToPosition(index) createContactFrom(it) })) } } companion object { private const val IS_A_FACEBOOK_CONTACT = AnnualEventsContract.SOURCE + "== " + SOURCE_FACEBOOK private fun createContactFrom(cursor: Cursor): Contact { val uid = cursor.getLong(cursor.getColumnIndexOrThrow(AnnualEventsContract.CONTACT_ID)) val displayName = DisplayName.from(cursor.getString(cursor.getColumnIndexOrThrow(AnnualEventsContract.DISPLAY_NAME))) val imagePath = FacebookImagePath.forUid(uid) return Contact(uid, displayName, imagePath, SOURCE_FACEBOOK) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/AlarmManagerCompat.java ================================================ package com.alexstyl.specialdates.dailyreminder; import android.app.AlarmManager; import android.app.PendingIntent; import com.alexstyl.android.Version; public final class AlarmManagerCompat { private final AlarmManager alarmManager; public AlarmManagerCompat(AlarmManager alarmManager) { this.alarmManager = alarmManager; } public void setExact(int type, long triggerAtmillis, PendingIntent operation) { if (Version.INSTANCE.hasMarshmallow()) { alarmManager.setAndAllowWhileIdle(type, triggerAtmillis, operation); } else if (Version.INSTANCE.hasKitKat()) { alarmManager.setExact(type, triggerAtmillis, operation); } else { alarmManager.set(AlarmManager.RTC, triggerAtmillis, operation); } } public void cancel(PendingIntent pendingIntent) { alarmManager.cancel(pendingIntent); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/AndroidDailyReminderNotifier.kt ================================================ package com.alexstyl.specialdates.dailyreminder import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.PorterDuffXfermode import android.graphics.Rect import android.graphics.RectF import android.provider.ContactsContract.Contacts.CONTENT_LOOKUP_URI import android.support.v4.app.NotificationCompat import com.alexstyl.android.Version import com.alexstyl.resources.Colors import com.alexstyl.specialdates.R import com.alexstyl.specialdates.dailyreminder.actions.PersonActionsActivity import com.alexstyl.specialdates.events.namedays.activity.NamedaysOnADayActivity import com.alexstyl.specialdates.home.HomeActivity import com.alexstyl.specialdates.images.ImageLoader import com.alexstyl.specialdates.person.PersonActivity import java.net.URI class AndroidDailyReminderNotifier(private val context: Context, private val notificationManager: NotificationManager, private val imageLoader: ImageLoader, private val colors: Colors) : DailyReminderNotifier { override fun notifyFor(viewModel: DailyReminderViewModel) { if (viewModel.contacts.isNotEmpty()) { when { supportsNotificationGroupping() -> { notifyContacts(viewModel.contacts) notifySummary(viewModel) } viewModel.contacts.size == 1 -> notifyContacts(viewModel.contacts) else -> notifySummary(viewModel) } } if (viewModel.namedays.isPresent) { notifyNamedays(viewModel.namedays.get()) } if (viewModel.bankHoliday.isPresent) { notifyBankHolidays(viewModel.bankHoliday.get()) } } private fun supportsNotificationGroupping() = Version.hasOreo() private fun notifyContacts(viewModels: List) { viewModels.forEach { viewModel -> val requestCode = NotificationConstants.CHANNEL_ID_CONTACTS.hashCode() + viewModel.hashCode() val startIntent = PersonActivity.buildIntentFor(context, viewModel.contact) val pendingIntent = PendingIntent.getActivity( context, requestCode, startIntent, PendingIntent.FLAG_UPDATE_CURRENT) val notification = NotificationCompat.Builder(context, NotificationConstants.CHANNEL_ID_CONTACTS) .setContentTitle(viewModel.title) .setContentText(viewModel.label) .setContentIntent(pendingIntent) .setActions(viewModel) .loadLargeImage(viewModel.contact.imagePath) .setSmallIcon(R.drawable.ic_stat_memento) .setColor(colors.getDailyReminderColor()) .setGroup(NotificationConstants.DAILY_REMINDER_GROUP_ID) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) .setAutoCancel(true) .addPerson(CONTENT_LOOKUP_URI.buildUpon().appendPath(viewModel.contact.contactID.toString()).build().toString()) .build() notificationManager.notify(viewModel.notificationId, notification) } } private fun notifySummary(viewModel: DailyReminderViewModel) { val startIntent = HomeActivity.getStartIntent(context) val requestCode = NotificationConstants.NOTIFICATION_ID_CONTACTS_SUMMARY val pendingIntent = PendingIntent.getActivity( context, requestCode, startIntent, PendingIntent.FLAG_UPDATE_CURRENT) val summary = viewModel.summaryViewModel val notification = NotificationCompat.Builder(context, NotificationConstants.CHANNEL_ID_CONTACTS) .setContentTitle(summary.title) .setContentText(summary.text) .setContentIntent(pendingIntent) .setSmallIcon(R.drawable.ic_stat_memento) .setColor(colors.getDailyReminderColor()) .setGroupSummary(true) .setGroup(NotificationConstants.DAILY_REMINDER_GROUP_ID) .setInboxStyle(viewModel.summaryViewModel) .build() notificationManager.notify(summary.notificationId, notification) } private fun notifyBankHolidays(bankHoliday: BankHolidayNotificationViewModel) { val startIntent = HomeActivity.getStartIntent(context) val requestCode = NotificationConstants.NOTIFICATION_ID_BANK_HOLIDAY val pendingIntent = PendingIntent.getActivity( context, requestCode, startIntent, PendingIntent.FLAG_UPDATE_CURRENT) val notification = NotificationCompat.Builder(context, NotificationConstants.CHANNEL_ID_BANKHOLIDAY) .setContentTitle(bankHoliday.title) .setContentText(bankHoliday.label) .setContentIntent(pendingIntent) .setSmallIcon(R.drawable.ic_stat_bankholidays) .setColor(colors.getBankholidaysColor()) .build() notificationManager.notify(NotificationConstants.NOTIFICATION_ID_BANK_HOLIDAY, notification) } private fun notifyNamedays(namedays: NamedaysNotificationViewModel) { val startIntent = NamedaysOnADayActivity.getStartIntent(context, namedays.date) val requestCode = NotificationConstants.NOTIFICATION_ID_NAMEDAYS val pendingIntent = PendingIntent.getActivity( context, requestCode, startIntent, PendingIntent.FLAG_UPDATE_CURRENT) val notification = NotificationCompat.Builder(context, NotificationConstants.CHANNEL_ID_NAMEDAYS) .setContentTitle(namedays.title) .setContentText(namedays.label) .setStyle(NotificationCompat.BigTextStyle().bigText(namedays.label)) .setContentIntent(pendingIntent) .setSmallIcon(R.drawable.ic_stat_namedays) .setColor(colors.getNamedaysColor()) .build() notificationManager.notify(NotificationConstants.NOTIFICATION_ID_NAMEDAYS, notification) } override fun cancelAllEvents() { notificationManager.cancel(NotificationConstants.NOTIFICATION_ID_CONTACTS_SUMMARY) notificationManager.cancel(NotificationConstants.NOTIFICATION_ID_BANK_HOLIDAY) notificationManager.cancel(NotificationConstants.NOTIFICATION_ID_NAMEDAYS) } private fun NotificationCompat.Builder.loadLargeImage(imagePath: URI): NotificationCompat.Builder = apply { val width = context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width) val height = context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_height) val bitmap = imageLoader .load(imagePath) .withSize(width, height) .synchronously() if (bitmap.isPresent) { if (Version.hasLollipop()) { setLargeIcon(bitmap.get().toCircle()) } else { setLargeIcon(bitmap.get()) } } } private fun Bitmap.toCircle(): Bitmap? { val output = Bitmap.createBitmap( this.width, this.height, Bitmap.Config.ARGB_8888 ) val canvas = Canvas(output) val color = Color.RED val paint = Paint() val rect = Rect(0, 0, this.width, this.height) val rectF = RectF(rect) paint.isAntiAlias = true canvas.drawARGB(0, 0, 0, 0) paint.color = color canvas.drawOval(rectF, paint) paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) canvas.drawBitmap(this, rect, rect, paint) return output } private fun NotificationCompat.Builder.setActions(contactEventViewModel: ContactEventNotificationViewModel): NotificationCompat.Builder { contactEventViewModel.actions.forEach { actionViewModel -> val intent = buildIntentFor(actionViewModel, contactEventViewModel) val pendingIntent = PendingIntent.getActivity(context, actionViewModel.id, intent, 0) addAction(NotificationCompat.Action(0, actionViewModel.label, pendingIntent)) } return this } private fun buildIntentFor(actionViewModel: ContactActionViewModel, contactEventViewModel: ContactEventNotificationViewModel): Intent = when (actionViewModel.type) { ActionType.CALL -> PersonActionsActivity.buildCallIntentFor(context, contactEventViewModel.contact) ActionType.SEND_WISH -> PersonActionsActivity.buildSendIntentFor(context, contactEventViewModel.contact) } } private fun NotificationCompat.Builder.setInboxStyle(viewModel: SummaryNotificationViewModel): NotificationCompat.Builder { val inboxStyle = NotificationCompat.InboxStyle() viewModel.lines.forEach { line -> inboxStyle.addLine(line) } setStyle(inboxStyle) return this } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/AndroidDailyReminderScheduler.kt ================================================ package com.alexstyl.specialdates.dailyreminder import com.alexstyl.specialdates.TimeOfDay import com.evernote.android.job.DailyJob import com.evernote.android.job.JobManager import com.evernote.android.job.JobRequest import java.util.concurrent.TimeUnit class AndroidDailyReminderScheduler : DailyReminderScheduler { @JvmField val ONE_HOUR = TimeUnit.HOURS.toMillis(1) override fun scheduleReminderFor(timeOfDay: TimeOfDay) { DailyJob.schedule(JobRequest.Builder(DailyReminderJob.TAG), timeOfDay.toMillis(), timeOfDay.toMillis() + ONE_HOUR ) } override fun cancelReminder() { JobManager.instance().cancelAllForTag(DailyReminderJob.TAG) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/AndroidDailyReminderViewModelFactory.kt ================================================ package com.alexstyl.specialdates.dailyreminder import android.graphics.Typeface import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import com.alexstyl.resources.Colors import com.alexstyl.specialdates.Strings import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.date.ContactEvent import com.alexstyl.specialdates.date.Date import com.alexstyl.specialdates.events.bankholidays.BankHoliday import com.alexstyl.specialdates.events.namedays.NamesInADate import com.alexstyl.specialdates.events.peopleevents.StandardEventType import com.alexstyl.specialdates.util.NaturalLanguageUtils import java.net.URI class AndroidDailyReminderViewModelFactory(private val strings: Strings, private val todaysDate: Date, private val colors: Colors) : DailyReminderViewModelFactory { override fun summaryOf(viewModels: List): SummaryNotificationViewModel { val contacts = viewModels.fold(emptyList(), { list, viewModel -> list + viewModel.contact }) val title = NaturalLanguageUtils.joinContacts(strings, contacts, MAX_CONTACTS) val label = strings.dontForgetToSendWishes() val lines = arrayListOf() viewModels.forEach { contactViewModel -> val boldedTitle = SpannableString("${contactViewModel.title}\t\t${contactViewModel.label}").apply { setSpan(StyleSpan(Typeface.BOLD), 0, contactViewModel.title.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } lines.add(boldedTitle) } val images = viewModels.fold(emptyList(), { list, viewModel -> list + viewModel.contact.imagePath }) return SummaryNotificationViewModel( NotificationConstants.NOTIFICATION_ID_CONTACTS_SUMMARY, title, label, lines, images ) } override fun viewModelFor(contact: Contact, events: List): ContactEventNotificationViewModel { val stringBuilder = SpannableStringBuilder() events.forEach { contactEvent -> val coloredLabel = SpannableString(contactEvent.getLabel(todaysDate, strings) + " " + emojiFor(contactEvent)).apply { setSpan(ForegroundColorSpan(colors.getColorFor(contactEvent.type)), 0, this.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } if (stringBuilder.isNotEmpty()) { stringBuilder.append(", ") } stringBuilder.append(coloredLabel) } return ContactEventNotificationViewModel(events.hashCode(), contact, contact.displayName.toString(), stringBuilder, emptyList() // TODO feature coming from the notification_actions branch ) } private fun emojiFor(contactEvent: ContactEvent): CharSequence = when (contactEvent.type) { StandardEventType.BIRTHDAY -> "🍰" StandardEventType.NAMEDAY -> "🎈" StandardEventType.ANNIVERSARY -> "💍" StandardEventType.OTHER -> "🌸" else -> "" } override fun viewModelFor(namedays: NamesInADate): NamedaysNotificationViewModel { return NamedaysNotificationViewModel(namedays.date, strings.todaysNamedays(namedays.getNames().size), namedays.getNames().joinToString(", ")) } override fun viewModelFor(bankHoliday: BankHoliday): BankHolidayNotificationViewModel = BankHolidayNotificationViewModel(bankHoliday.holidayName, strings.bankholidaySubtitle()) companion object { private const val MAX_CONTACTS = 3 } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/DailyReminderDebugPreferences.java ================================================ package com.alexstyl.specialdates.dailyreminder; import android.content.Context; import android.support.v4.util.Pair; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.date.Date; import com.alexstyl.specialdates.date.MonthInt; import com.alexstyl.specialdates.date.Months; public final class DailyReminderDebugPreferences { private final EasyPreferences preferences; public static DailyReminderDebugPreferences newInstance(Context context) { return new DailyReminderDebugPreferences(EasyPreferences.createForPrivatePreferences(context, R.string.pref_dailyreminder_debug)); } private DailyReminderDebugPreferences(EasyPreferences preferences) { this.preferences = preferences; } @SuppressWarnings("magicNumber") public Date getSelectedDate() { int dayOfMonth = preferences.getInt(R.string.key_debug_daily_reminder_date_fake_day, 1); @MonthInt int month = preferences.getInt(R.string.key_debug_daily_reminder_date_fake_month, Months.JANUARY); int year = preferences.getInt(R.string.key_debug_daily_reminder_date_fake_year, 2016); return Date.Companion.on(dayOfMonth, month, year); } boolean isFakeDateEnabled() { return preferences.getBoolean(R.string.key_debug_daily_reminder_date_enable, false); } public void setSelectedDate(int dayOfMonth, int month, int year) { Pair dayPair = new Pair<>(R.string.key_debug_daily_reminder_date_fake_day, dayOfMonth); Pair monthPair = new Pair<>(R.string.key_debug_daily_reminder_date_fake_month, month); Pair yearPair = new Pair<>(R.string.key_debug_daily_reminder_date_fake_year, year); //noinspection unchecked preferences.setIntegers(dayPair, monthPair, yearPair); } public void setEnabled(boolean newValue) { preferences.setBoolean(R.string.key_debug_daily_reminder_date_enable, newValue); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/DailyReminderJob.kt ================================================ package com.alexstyl.specialdates.dailyreminder import com.evernote.android.job.DailyJob class DailyReminderJob(private val presenter: DailyReminderPresenter, private val notifier: DailyReminderNotifier) : DailyJob() { companion object { const val TAG = "Daily_reminder" } override fun onRunDailyJob(params: Params): DailyJobResult { val view = NotificationDailyReminderView(notifier) presenter.startPresentingInto(view) presenter.stopPresenting() return DailyJobResult.SUCCESS } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/DailyReminderModule.java ================================================ package com.alexstyl.specialdates.dailyreminder; import android.app.NotificationManager; import android.content.Context; import com.alexstyl.Logger; import com.alexstyl.resources.Colors; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.Strings; import com.alexstyl.specialdates.date.Date; import com.alexstyl.specialdates.events.bankholidays.BankHolidayProvider; import com.alexstyl.specialdates.events.bankholidays.BankHolidaysUserSettings; import com.alexstyl.specialdates.events.namedays.NamedayUserSettings; import com.alexstyl.specialdates.events.namedays.calendar.resource.NamedayCalendarProvider; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsProvider; import com.alexstyl.specialdates.images.ImageLoader; import com.alexstyl.specialdates.permissions.MementoPermissions; import com.alexstyl.specialdates.settings.DailyReminderNavigator; import dagger.Module; import dagger.Provides; @Module public class DailyReminderModule { @Provides DailyReminderUserSettings settings(Context context) { EasyPreferences defaultPreferences = EasyPreferences.createForDefaultPreferences(context); return new DailyReminderPreferences(defaultPreferences); } @Provides DailyReminderViewModelFactory factory(Strings strings, Colors colors) { return new AndroidDailyReminderViewModelFactory(strings, Date.Companion.today(), colors); } @Provides DailyReminderPresenter presenter(MementoPermissions permissions, PeopleEventsProvider peopleEventsProvider, NamedayUserSettings namedaySettings, BankHolidaysUserSettings bankholidaySettings, NamedayCalendarProvider namedayCalendarProvider, DailyReminderViewModelFactory factory, CrashAndErrorTracker errorTracker, BankHolidayProvider bankHolidayProvider) { return new DailyReminderPresenter( permissions, peopleEventsProvider, namedaySettings, bankholidaySettings, namedayCalendarProvider, factory, errorTracker, bankHolidayProvider ); } @Provides DailyReminderNotifier notifier(Context context, ImageLoader imageLoader, Colors colors, NotificationManager notificationManager) { return new AndroidDailyReminderNotifier(context, notificationManager, imageLoader, colors); } @Provides DailyReminderOreoChannelCreator channelCreator(NotificationManager notificationManager, Strings strings, Logger logger, DailyReminderUserSettings preferences) { return new DailyReminderOreoChannelCreator(notificationManager, strings, preferences, logger); } @Provides DailyReminderNavigator navigator() { return new DailyReminderNavigator(); } @Provides DailyReminderScheduler scheduler() { return new AndroidDailyReminderScheduler(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/DailyReminderOreoChannelCreator.kt ================================================ package com.alexstyl.specialdates.dailyreminder import android.annotation.TargetApi import android.app.NotificationChannel import android.app.NotificationChannelGroup import android.app.NotificationManager import android.graphics.Color import android.media.AudioAttributes import android.os.Build import com.alexstyl.Logger import com.alexstyl.android.Version import com.alexstyl.android.toUri import com.alexstyl.specialdates.Strings @TargetApi(Build.VERSION_CODES.O) class DailyReminderOreoChannelCreator(private val notificationManager: NotificationManager, private val strings: Strings, private val dailyReminderPreferences: DailyReminderUserSettings, private val logger: Logger) { fun createDailyReminderChannel() { if (!Version.hasOreo()) { return } val group = NotificationChannelGroup(NotificationConstants.DAILY_REMINDER_GROUP_ID, strings.dailyReminder()) if (notificationManager.notificationChannelGroups.contains(group)) { logger.warning("Already contains Group '${group.name}'. Won't create new channels [$group]") return } notificationManager.createNotificationChannelGroup(group) createContactsChannel() createNamedayChannel() createBankHolidayChannel() } private fun createContactsChannel() { val contactsChannel = NotificationChannel( NotificationConstants.CHANNEL_ID_CONTACTS, strings.contacts(), NotificationManager.IMPORTANCE_DEFAULT) contactsChannel.group = NotificationConstants.DAILY_REMINDER_GROUP_ID contactsChannel.enableLights(true) contactsChannel.lightColor = Color.RED contactsChannel.enableVibration(dailyReminderPreferences.isVibrationEnabled()) contactsChannel.setSound(dailyReminderPreferences.getRingtone().toUri(), AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION) .build()) notificationManager.createNotificationChannel(contactsChannel) } private fun createNamedayChannel() { val namedaysChannel = NotificationChannel( NotificationConstants.CHANNEL_ID_NAMEDAYS, strings.namedays(), NotificationManager.IMPORTANCE_LOW) namedaysChannel.group = NotificationConstants.DAILY_REMINDER_GROUP_ID namedaysChannel.enableLights(false) namedaysChannel.enableVibration(dailyReminderPreferences.isVibrationEnabled()) namedaysChannel.setSound(dailyReminderPreferences.getRingtone().toUri(), AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION) .build()) notificationManager.createNotificationChannel(namedaysChannel) } private fun createBankHolidayChannel() { val bankHolidaysChannel = NotificationChannel( NotificationConstants.CHANNEL_ID_BANKHOLIDAY, strings.bankholidays(), NotificationManager.IMPORTANCE_LOW) bankHolidaysChannel.group = NotificationConstants.DAILY_REMINDER_GROUP_ID bankHolidaysChannel.enableLights(false) bankHolidaysChannel.enableVibration(dailyReminderPreferences.isVibrationEnabled()) bankHolidaysChannel.setSound(dailyReminderPreferences.getRingtone().toUri(), AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_NOTIFICATION) .build()) notificationManager.createNotificationChannel(bankHolidaysChannel) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/DailyReminderPreferences.kt ================================================ package com.alexstyl.specialdates.dailyreminder import android.media.RingtoneManager import android.net.Uri import com.alexstyl.specialdates.EasyPreferences import com.alexstyl.specialdates.R import com.alexstyl.specialdates.TimeOfDay import java.net.URI class DailyReminderPreferences(private val preferences: EasyPreferences) : DailyReminderUserSettings { override fun setEnabled(isEnabled: Boolean) { preferences.setBoolean(R.string.key_daily_reminder, isEnabled) } override fun isEnabled(): Boolean = preferences.getBoolean(R.string.key_daily_reminder, true) override fun getTimeSet(): TimeOfDay { val time = preferences .getString(R.string.key_daily_reminder_time, DEFAULT_DAILY_REMINDER_TIME) .split(":") return TimeOfDay(time[0].toInt(), time[1].toInt()) } override fun getRingtone(): URI { val selectedRingtone = preferences.getString(R.string.key_daily_reminder_ringtone, null) return selectedRingtone?.toURI() ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION).toURI() } override fun isVibrationEnabled(): Boolean = preferences.getBoolean(R.string.key_daily_reminder_vibrate_enabled, false) override fun setDailyReminderTime(time: TimeOfDay) { preferences.setString(R.string.key_daily_reminder_time, time.toString()) } private fun Uri.toURI(): URI = this.toString().toURI() private fun String.toURI(): URI = URI.create(this) companion object { private const val DEFAULT_DAILY_REMINDER_TIME = "08:00" } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/NoActions.kt ================================================ package com.alexstyl.specialdates.dailyreminder import com.alexstyl.specialdates.person.ContactActions import java.net.URI class NoActions : ContactActions { override fun dial(phoneNumber: String) = { // do nothing } override fun view(data: URI, mimetype: String) = { // do nothing } override fun view(data: URI) = { // do nothing } override fun message(phoneNumber: String) = { // do nothing } override fun email(emailAdress: String) = { // do nothing } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false return true } override fun hashCode(): Int { return javaClass.hashCode() } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/NotificationConstants.kt ================================================ package com.alexstyl.specialdates.dailyreminder import com.alexstyl.specialdates.BuildConfig object NotificationConstants { const val CHANNEL_ID_CONTACTS = BuildConfig.APPLICATION_ID + ".channel.contacts" const val CHANNEL_ID_NAMEDAYS = BuildConfig.APPLICATION_ID + ".channel.namedays" const val CHANNEL_ID_BANKHOLIDAY = BuildConfig.APPLICATION_ID + ".channel.bankholiday" const val NOTIFICATION_ID_CONTACTS_SUMMARY: Int = 4001 const val NOTIFICATION_ID_NAMEDAYS = 4002 const val NOTIFICATION_ID_BANK_HOLIDAY = 4003 const val DAILY_REMINDER_GROUP_ID = BuildConfig.APPLICATION_ID + ".group.daily_reminder" } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/NotificationDailyReminderView.kt ================================================ package com.alexstyl.specialdates.dailyreminder class NotificationDailyReminderView(private val notifier: DailyReminderNotifier) : DailyReminderView { override fun show(viewModel: DailyReminderViewModel) { notifier.notifyFor(viewModel) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/actions/AndroidContactActionsView.kt ================================================ package com.alexstyl.specialdates.dailyreminder.actions import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.person.ContactActionViewModel import com.alexstyl.specialdates.person.ContactActionsAdapter class AndroidContactActionsView(private val contact: Contact, private val recyclerView: ContactActionsAdapter) : ContactActionsView { override fun display(viewModels: List) { recyclerView.displayCallMethods(viewModels) } override fun contact(): Contact = contact } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/actions/ContactActionsModule.java ================================================ package com.alexstyl.specialdates.dailyreminder.actions; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.contact.ContactIntentExtractor; import com.alexstyl.specialdates.contact.ContactsProvider; import com.alexstyl.specialdates.person.ContactActionsProvider; import dagger.Module; import dagger.Provides; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; @Module public class ContactActionsModule { @Provides ContactActionsPresenter presenter(ContactActionsProvider personActionsProvider) { return new ContactActionsPresenter(personActionsProvider, Schedulers.io(), AndroidSchedulers.mainThread()); } @Provides ContactIntentExtractor extractor(CrashAndErrorTracker tracker, ContactsProvider contactProvider) { return new ContactIntentExtractor(tracker, contactProvider); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/actions/ContactActionsPresenter.kt ================================================ package com.alexstyl.specialdates.dailyreminder.actions import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.person.ContactActions import com.alexstyl.specialdates.person.ContactActionsProvider import io.reactivex.Observable import io.reactivex.Scheduler import io.reactivex.disposables.Disposable class ContactActionsPresenter( private val personActionsProvider: ContactActionsProvider, private val workScheduler: Scheduler, private val resultScheduler: Scheduler) { private var disposable: Disposable? = null fun startPresentingCallsInto(view: ContactActionsView, contactActions: ContactActions) { disposable = callActionsFor(contactActions, view.contact()) .observeOn(resultScheduler) .subscribeOn(workScheduler) .subscribe { viewModels -> view.display(viewModels) } } private fun callActionsFor(contactActions: ContactActions, contact: Contact) = Observable.fromCallable { personActionsProvider.callActionsFor(contact, contactActions) } fun startPresentingMessagingInto(view: ContactActionsView, contactActions: ContactActions) { disposable = messagingActionsFor(contactActions, view.contact()) .observeOn(resultScheduler) .subscribeOn(workScheduler) .subscribe { viewModels -> view.display(viewModels) } } private fun messagingActionsFor(contactActions: ContactActions, contact: Contact) = Observable.fromCallable { personActionsProvider.messagingActionsFor(contact, contactActions) } fun stopPresenting() { disposable?.dispose() } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/actions/ContactActionsView.kt ================================================ package com.alexstyl.specialdates.dailyreminder.actions import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.person.ContactActionViewModel interface ContactActionsView { fun display(viewModels: List) fun contact(): Contact } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/dailyreminder/actions/PersonActionsActivity.kt ================================================ package com.alexstyl.specialdates.dailyreminder.actions import android.content.Context import android.content.Intent import android.content.Intent.ACTION_CALL import android.content.Intent.ACTION_SENDTO import android.os.Bundle import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.MementoApplication import com.alexstyl.specialdates.R import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.contact.ContactIntentExtractor import com.alexstyl.specialdates.person.AndroidContactActions import com.alexstyl.specialdates.person.ContactActionsAdapter import com.alexstyl.specialdates.ui.base.ThemedMementoActivity import javax.inject.Inject class PersonActionsActivity : ThemedMementoActivity() { var presenter: ContactActionsPresenter? = null @Inject set var extractor: ContactIntentExtractor? = null @Inject set var errorTracker: CrashAndErrorTracker? = null @Inject set var view: ContactActionsView? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_call) (application as MementoApplication).applicationModule.inject(this) val recyclerView = findViewById(R.id.actions_list)!! recyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) val adapter = ContactActionsAdapter { it.action.run() finish() } recyclerView.adapter = adapter val contact = extractor?.getContactExtra(intent) if (contact != null && contact.isPresent) { view = AndroidContactActionsView(contact.get(), adapter) } else { errorTracker?.track(RuntimeException("Tried to load the actions for a contact from $intent")) finish() } } override fun onStart() { super.onStart() if (view != null) { startPresentingInto(view!!) } } private fun startPresentingInto(view: ContactActionsView) { val action = AndroidContactActions(this) when { intent.action == ACTION_CALL -> presenter?.startPresentingCallsInto(view, action) intent.action == ACTION_SENDTO -> presenter?.startPresentingMessagingInto(view, action) else -> { } } } override fun onStop() { super.onStop() presenter?.stopPresenting() } companion object { fun buildCallIntentFor(context: Context, contact: Contact): Intent { return Intent(context, PersonActionsActivity::class.java) .setAction(ACTION_CALL) .putContactExtra(contact) } fun buildSendIntentFor(context: Context, contact: Contact): Intent { return Intent(context, PersonActionsActivity::class.java) .setAction(ACTION_SENDTO) .putContactExtra(contact) } private fun Intent.putContactExtra(contact: Contact): Intent { return putExtra(ContactIntentExtractor.EXTRA_CONTACT_ID, contact.contactID) .putExtra(ContactIntentExtractor.EXTRA_CONTACT_SOURCE, contact.source) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/date/AndroidDateLabelCreator.kt ================================================ package com.alexstyl.specialdates.date import android.content.Context import android.text.format.DateUtils class AndroidDateLabelCreator(private val context: Context) : DateLabelCreator { override fun createLabelWithoutYear(date: Date): String { val formatFlags = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR return DateUtils.formatDateTime(context, date.toMillis(), formatFlags) } override fun createWithYearPreferred(date: Date): String { return if (date.hasYear()) { val formatFlags = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR DateUtils.formatDateTime(context, date.toMillis(), formatFlags) } else { val formatFlags = DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR DateUtils.formatDateTime(context, date.toMillis(), formatFlags) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/date/DateModule.java ================================================ package com.alexstyl.specialdates.date; import android.content.Context; import com.alexstyl.specialdates.CrashAndErrorTracker; import javax.inject.Singleton; import dagger.Module; import dagger.Provides; @Module @Singleton public class DateModule { @Provides @Singleton DateLabelCreator labelCreator(Context context) { return new AndroidDateLabelCreator(context); } @Provides DateParser dateParser(CrashAndErrorTracker errorTracker) { return new DateParser(errorTracker); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/AndroidDonation.java ================================================ package com.alexstyl.specialdates.donate; enum AndroidDonation implements Donation { SKU_DONATE_1("1€", "donate_1"), SKU_DONATE_2("3€", "donate_2"), SKU_DONATE_3("5€", "donate_3"), SKU_DONATE_4("8€", "donate_4"), SKU_DONATE_5("10€", "donate_5"), SKU_DONATE_6("15€", "donate_6"), SKU_DONATE_7("20€", "donate_7"); private final String priceLabel; private final String identifier; AndroidDonation(String priceLabel, String identifier) { this.priceLabel = priceLabel; this.identifier = identifier; } @Override public String getAmount() { return priceLabel; } @Override public String getIdentifier() { return identifier; } public static Donation valueOfIndex(int index) { return values()[index]; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/AndroidDonationConstants.java ================================================ package com.alexstyl.specialdates.donate; public final class AndroidDonationConstants { private AndroidDonationConstants() { // hide this } public static final String PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsYY0f8jTzL1RkaxI6RgasYZ1anbjHo3uOrSecriRJdQtTtiynmnjSqg0mh79gJunqbqtJtQQaFYo" + "wm1D5uTWkZ30CmZqrKAz8PILbF7andUNECZRuUENfsptHhaBQHYcDucXLP2QDuerEaYPcBW4kQoyv4Jyfm/" + "vDou04VwMADcFQ4vZ/Rj6tvt+KNXvMbJorQslDSmA3Ul+oDqDJ1K0TFPFOv3ECjuw+J/g0TX6yAcS9LR8xHVppWE9fW0+qPWd2tT" + "o0CIb3W3h+lgREkDZEGRlWvGijWmND7qFbhCv2jURen851trNSLIvyOQwraCVku6VkxSxwCeS2E26q7B5mQIDAQAB"; } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/AndroidDonationService.java ================================================ package com.alexstyl.specialdates.donate; import android.app.Activity; import android.widget.Toast; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.analytics.Analytics; import com.alexstyl.specialdates.donate.util.IabHelper; import com.alexstyl.specialdates.donate.util.IabResult; import com.alexstyl.specialdates.donate.util.Inventory; import com.alexstyl.specialdates.donate.util.Purchase; public class AndroidDonationService implements DonationService { private final IabHelper iabHelper; private final Activity activity; private final DonationPreferences donationPreferences; private final Analytics analytics; private final CrashAndErrorTracker tracker; private final DonateMonitor monitor; private DonationCallbacks listener; public AndroidDonationService(IabHelper iabHelper, Activity activity, DonationPreferences donationPreferences, Analytics analytics, CrashAndErrorTracker tracker, DonateMonitor monitor) { this.iabHelper = iabHelper; this.activity = activity; this.donationPreferences = donationPreferences; this.analytics = analytics; this.tracker = tracker; this.monitor = monitor; } @Override public void setup(final DonationCallbacks listener) { this.listener = listener; iabHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() { @Override public void onIabSetupFinished(IabResult result) { if (result.isFailure()) { listener.onDonateException(result.getMessage()); } } }); } @Override public void placeDonation(final Donation donation, int requestCode) { try { iabHelper.launchPurchaseFlow( activity, donation.getIdentifier(), requestCode, new IabHelper.OnIabPurchaseFinishedListener() { @Override public void onIabPurchaseFinished(IabResult result, Purchase info) { if (result.isSuccess()) { analytics.trackDonationPlaced(donation); listener.onDonationFinished(donation); donationPreferences.markAsDonated(); } } } ); } catch (IabHelper.IabAsyncInProgressException e) { tracker.track(e); listener.onDonateException(e.getMessage()); } } @Override public void dispose() { try { iabHelper.dispose(); } catch (Exception e) { e.printStackTrace(); } } @Override public void restoreDonations() { try { iabHelper.queryInventoryAsync(new IabHelper.QueryInventoryFinishedListener() { @Override public void onQueryInventoryFinished(IabResult result, Inventory inv) { boolean hasDonated = containsDonations(inv); if (hasDonated) { Toast.makeText(activity, R.string.donate_thanks_for_donating, Toast.LENGTH_SHORT).show(); donationPreferences.markAsDonated(); monitor.onDonationUpdated(); analytics.trackDonationRestored(); } else { Toast.makeText(activity, R.string.donate_no_donation_found, Toast.LENGTH_SHORT).show(); } } }); } catch (IabHelper.IabAsyncInProgressException e) { tracker.track(e); } } private static boolean containsDonations(Inventory inventory) { for (AndroidDonation donation : AndroidDonation.values()) { if (inventory.hasPurchase(donation.getIdentifier())) { return true; } } return false; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/DonateActivity.java ================================================ package com.alexstyl.specialdates.donate; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.support.design.widget.AppBarLayout; import android.support.design.widget.CoordinatorLayout; import android.support.v4.widget.NestedScrollView; import android.support.v7.widget.Toolbar; import android.view.View; import android.widget.Button; import android.widget.ImageView; import android.widget.SeekBar; import android.widget.Toast; import com.alexstyl.android.Version; import com.alexstyl.specialdates.AppComponent; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.MementoApplication; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.Strings; import com.alexstyl.specialdates.TextViewLabelSetter; import com.alexstyl.specialdates.analytics.Analytics; import com.alexstyl.specialdates.donate.util.IabHelper; import com.alexstyl.specialdates.images.ImageLoader; import com.alexstyl.specialdates.ui.LolipopHideStatusBarListener; import com.alexstyl.specialdates.ui.base.MementoActivity; import com.novoda.notils.caster.Views; import javax.inject.Inject; import java.net.URI; public class DonateActivity extends MementoActivity { private static final int REQUEST_CODE = 1004; private static final int SCROLL_DOWN_ANIMATION_DELAY = 2000; private static final URI DEV_IMAGE_URI = URI.create("http://alexstyl.com/memento-calendar/dev.jpg"); private static final int VELOCITY_Y = 50; private DonatePresenter donatePresenter; private SeekBar donateBar; private CoordinatorLayout coordinator; @Inject Analytics analytics; @Inject Strings strings; @Inject ImageLoader imageLoader; @Inject IabHelper iabHelper; @Inject DonationPreferences donationPreferences; @Inject CrashAndErrorTracker tracker; @Inject DonateMonitor monitor; @Inject DonateMonitor donateMonitor; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_donate); AppComponent applicationModule = ((MementoApplication) getApplication()).getApplicationModule(); applicationModule.inject(this); Toolbar toolbar = Views.findById(this, R.id.toolbar); setSupportActionBar(toolbar); coordinator = Views.findById(this, R.id.donate_coordinator); ImageView avatar = Views.findById(this, R.id.donate_avatar); imageLoader .load(DEV_IMAGE_URI) .into(avatar); final AppBarLayout appBarLayout = Views.findById(this, R.id.app_bar_layout); final NestedScrollView scrollView = Views.findById(this, R.id.scroll); if (Version.INSTANCE.hasLollipop()) { appBarLayout.addOnOffsetChangedListener(new LolipopHideStatusBarListener(getWindow())); } DonationService donationService = new AndroidDonationService(iabHelper, this, donationPreferences, analytics, tracker, donateMonitor); final Button donateButton = Views.findById(this, R.id.donate_place_donation); donateButton.requestFocus(); donatePresenter = new DonatePresenter(analytics, donationService, new TextViewLabelSetter(donateButton), strings); donateButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Donation donation = AndroidDonation.valueOfIndex(donateBar.getProgress()); donatePresenter.placeDonation(donation, REQUEST_CODE); } }); setupDonateBar(); donatePresenter.startPresenting(donationCallbacks()); scrollView.postDelayed(new Runnable() { @Override public void run() { scrollToDonate(); } private void scrollToDonate() { CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) appBarLayout.getLayoutParams(); AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); behavior.onNestedFling(coordinator, appBarLayout, null, 0, VELOCITY_Y, true); } }, SCROLL_DOWN_ANIMATION_DELAY); } private void setupDonateBar() { donateBar = Views.findById(this, R.id.donation_bar); donateBar.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { String amount = AndroidDonation.valueOfIndex(progress).getAmount(); donatePresenter.displaySelectedDonation(amount); } }); AndroidDonation[] values = AndroidDonation.values(); donateBar.setProgress(1); donateBar.setMax(values.length - 1); } @Override public void onDestroy() { super.onDestroy(); donatePresenter.stopPresenting(); } private DonationCallbacks donationCallbacks() { return new DonationCallbacks() { @Override public void onDonateException(String message) { tracker.track(new RuntimeException(message)); finish(); } @Override public void onDonationFinished(Donation donation) { monitor.onDonationUpdated(); Toast.makeText(DonateActivity.this, R.string.donate_thanks_for_donating, Toast.LENGTH_SHORT).show(); setResult(RESULT_OK); finish(); } }; } public static Intent createIntent(Context context) { return new Intent(context, DonateActivity.class); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/DonateModule.java ================================================ package com.alexstyl.specialdates.donate; import android.content.Context; import com.alexstyl.specialdates.donate.util.IabHelper; import javax.inject.Singleton; import dagger.Module; import dagger.Provides; @Module public class DonateModule { @Provides IabHelper providesIabHelper(Context context) { return new IabHelper(context, AndroidDonationConstants.PUBLIC_KEY); } @Provides DonationPreferences providesDonationPreferences(Context context) { return DonationPreferences.newInstance(context); } @Provides @Singleton DonateMonitor providesMonitor(){ return new DonateMonitor(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/DonatePresenter.java ================================================ package com.alexstyl.specialdates.donate; import com.alexstyl.specialdates.Strings; import com.alexstyl.specialdates.LabelSetter; import com.alexstyl.specialdates.analytics.Analytics; import com.alexstyl.specialdates.analytics.Screen; class DonatePresenter { private final Analytics analytics; private final DonationService donationService; private final LabelSetter donateButtonLabel; private final Strings strings; DonatePresenter(Analytics analytics, DonationService donationService, LabelSetter donateButtonLabel, Strings strings) { this.analytics = analytics; this.donationService = donationService; this.donateButtonLabel = donateButtonLabel; this.strings = strings; } void displaySelectedDonation(String amount) { donateButtonLabel.setLabel(strings.donateAmount(amount)); } void startPresenting(DonationCallbacks donationCallbacks) { analytics.trackScreen(Screen.DONATE); donationService.setup(donationCallbacks); } void placeDonation(Donation donation, int requestCode) { donationService.placeDonation(donation, requestCode); analytics.trackDonationStarted(donation); } void stopPresenting() { donationService.dispose(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/DonationPreferences.java ================================================ package com.alexstyl.specialdates.donate; import android.content.Context; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.R; public final class DonationPreferences { private final EasyPreferences preferences; public static DonationPreferences newInstance(Context context) { EasyPreferences preferences = EasyPreferences.createForDefaultPreferences(context); return new DonationPreferences(preferences); } private DonationPreferences(EasyPreferences preferences) { this.preferences = preferences; } void markAsDonated() { preferences.setBoolean(R.string.key_has_donated, true); } public boolean hasDonated() { return preferences.getBoolean(R.string.key_has_donated, false); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/SimpleOnSeekBarChangeListener.java ================================================ package com.alexstyl.specialdates.donate; import android.widget.SeekBar; class SimpleOnSeekBarChangeListener implements SeekBar.OnSeekBarChangeListener { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { // do nothing } @Override public void onStartTrackingTouch(SeekBar seekBar) { // do nothing } @Override public void onStopTrackingTouch(SeekBar seekBar) { // do nothing } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/IabBroadcastReceiver.java ================================================ /* Copyright (c) 2014 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; /** * Receiver for the "com.android.vending.billing.PURCHASES_UPDATED" Action * from the Play Store. * *

It is possible that an in-app item may be acquired without the * application calling getBuyIntent(), for example if the item can be * redeemed from inside the Play Store using a promotional code. If this * application isn't running at the time, then when it is started a call * to getPurchases() will be sufficient notification. However, if the * application is already running in the background when the item is acquired, * a message to this BroadcastReceiver will indicate that the an item * has been acquired.

*/ public class IabBroadcastReceiver extends BroadcastReceiver { /** * DonationCallbacks interface for received broadcast messages. */ public interface IabBroadcastListener { void receivedBroadcast(); } /** * The Intent action that this Receiver should filter for. */ public static final String ACTION = "com.android.vending.billing.PURCHASES_UPDATED"; private final IabBroadcastListener mListener; public IabBroadcastReceiver(IabBroadcastListener listener) { mListener = listener; } @Override public void onReceive(Context context, Intent intent) { if (mListener != null) { mListener.receivedBroadcast(); } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/IabException.java ================================================ /* Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; /** * Exception thrown when something went wrong with in-app billing. * An IabException has an associated IabResult (an error). * To get the IAB result that caused this exception to be thrown, * call {@link #getResult()}. */ public class IabException extends Exception { IabResult mResult; public IabException(IabResult r) { this(r, null); } public IabException(int response, String message) { this(new IabResult(response, message)); } public IabException(IabResult r, Exception cause) { super(r.getMessage(), cause); mResult = r; } public IabException(int response, String message, Exception cause) { this(new IabResult(response, message), cause); } /** Returns the IAB result (error) that this exception signals. */ public IabResult getResult() { return mResult; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/IabHelper.java ================================================ /* Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; import android.app.Activity; import android.app.PendingIntent; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentSender.SendIntentException; import android.content.ServiceConnection; import android.content.pm.ResolveInfo; import android.os.Bundle; import android.os.Handler; import android.os.IBinder; import android.os.RemoteException; import android.text.TextUtils; import android.util.Log; import com.android.vending.billing.IInAppBillingService; import java.util.ArrayList; import java.util.List; import org.json.JSONException; /** * Provides convenience methods for in-app billing. You can create one instance of this * class for your application and use it to process in-app billing operations. * It provides synchronous (blocking) and asynchronous (non-blocking) methods for * many common in-app billing operations, as well as automatic signature * verification. * * After instantiating, you must perform setup in order to start using the object. * To perform setup, call the {@link #startSetup} method and provide a listener; * that listener will be notified when setup is complete, after which (and not before) * you may call other methods. * * After setup is complete, you will typically want to request an inventory of owned * items and subscriptions. See {@link #queryInventory}, {@link #queryInventoryAsync} * and related methods. * * When you are done with this object, don't forget to call {@link #dispose} * to ensure proper cleanup. This object holds a binding to the in-app billing * service, which will leak unless you dispose of it correctly. If you created * the object on an Activity's onCreate method, then the recommended * place to dispose of it is the Activity's onDestroy method. It is invalid to * dispose the object while an asynchronous operation is in progress. You can * call {@link #disposeWhenFinished()} to ensure that any in-progress operation * completes before the object is disposed. * * A note about threading: When using this object from a background thread, you may * call the blocking versions of methods; when using from a UI thread, call * only the asynchronous versions and handle the results via callbacks. * Also, notice that you can only call one asynchronous operation at a time; * attempting to start a second asynchronous operation while the first one * has not yet completed will result in an exception being thrown. * */ public class IabHelper { // Is debug logging enabled? boolean mDebugLog = false; String mDebugTag = "IabHelper"; // Is setup done? boolean mSetupDone = false; // Has this object been disposed of? (If so, we should ignore callbacks, etc) boolean mDisposed = false; // Do we need to dispose this object after an in-progress asynchronous operation? boolean mDisposeAfterAsync = false; // Are subscriptions supported? boolean mSubscriptionsSupported = false; // Is subscription update supported? boolean mSubscriptionUpdateSupported = false; // Is an asynchronous operation in progress? // (only one at a time can be in progress) boolean mAsyncInProgress = false; // Ensure atomic access to mAsyncInProgress and mDisposeAfterAsync. private final Object mAsyncInProgressLock = new Object(); // (for logging/debugging) // if mAsyncInProgress == true, what asynchronous operation is in progress? String mAsyncOperation = ""; // Context we were passed during initialization Context mContext; // Connection to the service IInAppBillingService mService; ServiceConnection mServiceConn; // The request code used to launch purchase flow int mRequestCode; // The item type of the current purchase flow String mPurchasingItemType; // Public key for verifying signature, in base64 encoding String mSignatureBase64 = null; // Billing response codes public static final int BILLING_RESPONSE_RESULT_OK = 0; public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1; public static final int BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE = 2; public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3; public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4; public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5; public static final int BILLING_RESPONSE_RESULT_ERROR = 6; public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7; public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8; // IAB Helper error codes public static final int IABHELPER_ERROR_BASE = -1000; public static final int IABHELPER_REMOTE_EXCEPTION = -1001; public static final int IABHELPER_BAD_RESPONSE = -1002; public static final int IABHELPER_VERIFICATION_FAILED = -1003; public static final int IABHELPER_SEND_INTENT_FAILED = -1004; public static final int IABHELPER_USER_CANCELLED = -1005; public static final int IABHELPER_UNKNOWN_PURCHASE_RESPONSE = -1006; public static final int IABHELPER_MISSING_TOKEN = -1007; public static final int IABHELPER_UNKNOWN_ERROR = -1008; public static final int IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE = -1009; public static final int IABHELPER_INVALID_CONSUMPTION = -1010; public static final int IABHELPER_SUBSCRIPTION_UPDATE_NOT_AVAILABLE = -1011; // Keys for the responses from InAppBillingService public static final String RESPONSE_CODE = "RESPONSE_CODE"; public static final String RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST"; public static final String RESPONSE_BUY_INTENT = "BUY_INTENT"; public static final String RESPONSE_INAPP_PURCHASE_DATA = "INAPP_PURCHASE_DATA"; public static final String RESPONSE_INAPP_SIGNATURE = "INAPP_DATA_SIGNATURE"; public static final String RESPONSE_INAPP_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST"; public static final String RESPONSE_INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST"; public static final String RESPONSE_INAPP_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST"; public static final String INAPP_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN"; // Item types public static final String ITEM_TYPE_INAPP = "inapp"; public static final String ITEM_TYPE_SUBS = "subs"; // some fields on the getSkuDetails response bundle public static final String GET_SKU_DETAILS_ITEM_LIST = "ITEM_ID_LIST"; public static final String GET_SKU_DETAILS_ITEM_TYPE_LIST = "ITEM_TYPE_LIST"; /** * Creates an instance. After creation, it will not yet be ready to use. You must perform * setup by calling {@link #startSetup} and wait for setup to complete. This constructor does not * block and is safe to call from a UI thread. * * @param ctx Your application or Activity context. Needed to bind to the in-app billing service. * @param base64PublicKey Your application's public key, encoded in base64. * This is used for verification of purchase signatures. You can find your app's base64-encoded * public key in your application's page on Google Play Developer Console. Note that this * is NOT your "developer public key". */ public IabHelper(Context ctx, String base64PublicKey) { mContext = ctx.getApplicationContext(); mSignatureBase64 = base64PublicKey; logDebug("IAB helper created."); } /** * Enables or disable debug logging through LogCat. */ public void enableDebugLogging(boolean enable, String tag) { checkNotDisposed(); mDebugLog = enable; mDebugTag = tag; } public void enableDebugLogging(boolean enable) { checkNotDisposed(); mDebugLog = enable; } /** * Callback for setup process. This listener's {@link #onIabSetupFinished} method is called * when the setup process is complete. */ public interface OnIabSetupFinishedListener { /** * Called to notify that setup is complete. * * @param result The result of the setup process. */ void onIabSetupFinished(IabResult result); } /** * Starts the setup process. This will start up the setup process asynchronously. * You will be notified through the listener when the setup process is complete. * This method is safe to call from a UI thread. * * @param listener The listener to notify when the setup process is complete. */ public void startSetup(final OnIabSetupFinishedListener listener) { // If already set up, can't do it again. checkNotDisposed(); if (mSetupDone) throw new IllegalStateException("IAB helper is already set up."); // Connection to IAB service logDebug("Starting in-app billing setup."); mServiceConn = new ServiceConnection() { @Override public void onServiceDisconnected(ComponentName name) { logDebug("Billing service disconnected."); mService = null; } @Override public void onServiceConnected(ComponentName name, IBinder service) { if (mDisposed) return; logDebug("Billing service connected."); mService = IInAppBillingService.Stub.asInterface(service); String packageName = mContext.getPackageName(); try { logDebug("Checking for in-app billing 3 support."); // check for in-app billing v3 support int response = mService.isBillingSupported(3, packageName, ITEM_TYPE_INAPP); if (response != BILLING_RESPONSE_RESULT_OK) { if (listener != null) listener.onIabSetupFinished(new IabResult(response, "Error checking for billing v3 support.")); // if in-app purchases aren't supported, neither are subscriptions mSubscriptionsSupported = false; mSubscriptionUpdateSupported = false; return; } else { logDebug("In-app billing version 3 supported for " + packageName); } // Check for v5 subscriptions support. This is needed for // getBuyIntentToReplaceSku which allows for subscription update response = mService.isBillingSupported(5, packageName, ITEM_TYPE_SUBS); if (response == BILLING_RESPONSE_RESULT_OK) { logDebug("Subscription re-signup AVAILABLE."); mSubscriptionUpdateSupported = true; } else { logDebug("Subscription re-signup not available."); mSubscriptionUpdateSupported = false; } if (mSubscriptionUpdateSupported) { mSubscriptionsSupported = true; } else { // check for v3 subscriptions support response = mService.isBillingSupported(3, packageName, ITEM_TYPE_SUBS); if (response == BILLING_RESPONSE_RESULT_OK) { logDebug("Subscriptions AVAILABLE."); mSubscriptionsSupported = true; } else { logDebug("Subscriptions NOT AVAILABLE. Response: " + response); mSubscriptionsSupported = false; mSubscriptionUpdateSupported = false; } } mSetupDone = true; } catch (RemoteException e) { if (listener != null) { listener.onIabSetupFinished(new IabResult(IABHELPER_REMOTE_EXCEPTION, "RemoteException while setting up in-app billing.")); } e.printStackTrace(); return; } if (listener != null) { listener.onIabSetupFinished(new IabResult(BILLING_RESPONSE_RESULT_OK, "Setup successful.")); } } }; Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); serviceIntent.setPackage("com.android.vending"); List intentServices = mContext.getPackageManager().queryIntentServices(serviceIntent, 0); if (intentServices != null && !intentServices.isEmpty()) { // service available to handle that Intent mContext.bindService(serviceIntent, mServiceConn, Context.BIND_AUTO_CREATE); } else { // no service available to handle that Intent if (listener != null) { listener.onIabSetupFinished( new IabResult(BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, "Billing service unavailable on device.")); } } } /** * Dispose of object, releasing resources. It's very important to call this * method when you are done with this object. It will release any resources * used by it such as service connections. Naturally, once the object is * disposed of, it can't be used again. */ public void dispose() throws IabAsyncInProgressException { synchronized (mAsyncInProgressLock) { if (mAsyncInProgress) { throw new IabAsyncInProgressException("Can't dispose because an async operation " + "(" + mAsyncOperation + ") is in progress."); } } logDebug("Disposing."); mSetupDone = false; if (mServiceConn != null) { logDebug("Unbinding from service."); if (mContext != null) mContext.unbindService(mServiceConn); } mDisposed = true; mContext = null; mServiceConn = null; mService = null; mPurchaseListener = null; } /** * Disposes of object, releasing resources. If there is an in-progress async operation, this * method will queue the dispose to occur after the operation has finished. */ public void disposeWhenFinished() { synchronized (mAsyncInProgressLock) { if (mAsyncInProgress) { logDebug("Will dispose after async operation finishes."); mDisposeAfterAsync = true; } else { try { dispose(); } catch (IabAsyncInProgressException e) { // Should never be thrown, because we call dispose() only after checking that // there's not already an async operation in progress. } } } } private void checkNotDisposed() { if (mDisposed) throw new IllegalStateException("IabHelper was disposed of, so it cannot be used."); } /** Returns whether subscriptions are supported. */ public boolean subscriptionsSupported() { checkNotDisposed(); return mSubscriptionsSupported; } /** * Callback that notifies when a purchase is finished. */ public interface OnIabPurchaseFinishedListener { /** * Called to notify that an in-app purchase finished. If the purchase was successful, * then the sku parameter specifies which item was purchased. If the purchase failed, * the sku and extraData parameters may or may not be null, depending on how far the purchase * process went. * * @param result The result of the purchase. * @param info The purchase information (null if purchase failed) */ void onIabPurchaseFinished(IabResult result, Purchase info); } // The listener registered on launchPurchaseFlow, which we have to call back when // the purchase finishes OnIabPurchaseFinishedListener mPurchaseListener; public void launchPurchaseFlow(Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener) throws IabAsyncInProgressException { launchPurchaseFlow(act, sku, requestCode, listener, ""); } public void launchPurchaseFlow(Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener, String extraData) throws IabAsyncInProgressException { launchPurchaseFlow(act, sku, ITEM_TYPE_INAPP, null, requestCode, listener, extraData); } public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener) throws IabAsyncInProgressException { launchSubscriptionPurchaseFlow(act, sku, requestCode, listener, ""); } public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener, String extraData) throws IabAsyncInProgressException { launchPurchaseFlow(act, sku, ITEM_TYPE_SUBS, null, requestCode, listener, extraData); } /** * Initiate the UI flow for an in-app purchase. Call this method to initiate an in-app purchase, * which will involve bringing up the Google Play screen. The calling activity will be paused * while the user interacts with Google Play, and the result will be delivered via the * activity's {@link Activity#onActivityResult} method, at which point you must call * this object's {@link #handleActivityResult} method to continue the purchase flow. This method * MUST be called from the UI thread of the Activity. * * @param act The calling activity. * @param sku The sku of the item to purchase. * @param itemType indicates if it's a product or a subscription (ITEM_TYPE_INAPP or * ITEM_TYPE_SUBS) * @param oldSkus A list of SKUs which the new SKU is replacing or null if there are none * @param requestCode A request code (to differentiate from other responses -- as in * {@link Activity#startActivityForResult}). * @param listener The listener to notify when the purchase process finishes * @param extraData Extra data (developer payload), which will be returned with the purchase * data when the purchase completes. This extra data will be permanently bound to that * purchase and will always be returned when the purchase is queried. */ public void launchPurchaseFlow(Activity act, String sku, String itemType, List oldSkus, int requestCode, OnIabPurchaseFinishedListener listener, String extraData) throws IabAsyncInProgressException { checkNotDisposed(); checkSetupDone("launchPurchaseFlow"); flagStartAsync("launchPurchaseFlow"); IabResult result; if (itemType.equals(ITEM_TYPE_SUBS) && !mSubscriptionsSupported) { IabResult r = new IabResult(IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE, "Subscriptions are not available."); flagEndAsync(); if (listener != null) listener.onIabPurchaseFinished(r, null); return; } try { logDebug("Constructing buy intent for " + sku + ", item type: " + itemType); Bundle buyIntentBundle; if (oldSkus == null || oldSkus.isEmpty()) { // Purchasing a new item or subscription re-signup buyIntentBundle = mService.getBuyIntent(3, mContext.getPackageName(), sku, itemType, extraData); } else { // Subscription upgrade/downgrade if (!mSubscriptionUpdateSupported) { IabResult r = new IabResult(IABHELPER_SUBSCRIPTION_UPDATE_NOT_AVAILABLE, "Subscription updates are not available."); flagEndAsync(); if (listener != null) listener.onIabPurchaseFinished(r, null); return; } buyIntentBundle = mService.getBuyIntentToReplaceSkus(5, mContext.getPackageName(), oldSkus, sku, itemType, extraData); } int response = getResponseCodeFromBundle(buyIntentBundle); if (response != BILLING_RESPONSE_RESULT_OK) { logError("Unable to buy item, Error response: " + getResponseDesc(response)); flagEndAsync(); result = new IabResult(response, "Unable to buy item"); if (listener != null) listener.onIabPurchaseFinished(result, null); return; } PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT); logDebug("Launching buy intent for " + sku + ". Request code: " + requestCode); mRequestCode = requestCode; mPurchaseListener = listener; mPurchasingItemType = itemType; act.startIntentSenderForResult(pendingIntent.getIntentSender(), requestCode, new Intent(), Integer.valueOf(0), Integer.valueOf(0), Integer.valueOf(0)); } catch (SendIntentException e) { logError("SendIntentException while launching purchase flow for sku " + sku); e.printStackTrace(); flagEndAsync(); result = new IabResult(IABHELPER_SEND_INTENT_FAILED, "Failed to send intent."); if (listener != null) listener.onIabPurchaseFinished(result, null); } catch (RemoteException e) { logError("RemoteException while launching purchase flow for sku " + sku); e.printStackTrace(); flagEndAsync(); result = new IabResult(IABHELPER_REMOTE_EXCEPTION, "Remote exception while starting purchase flow"); if (listener != null) listener.onIabPurchaseFinished(result, null); } } /** * Handles an activity result that's part of the purchase flow in in-app billing. If you * are calling {@link #launchPurchaseFlow}, then you must call this method from your * Activity's {@link Activity@onActivityResult} method. This method * MUST be called from the UI thread of the Activity. * * @param requestCode The requestCode as you received it. * @param resultCode The resultCode as you received it. * @param data The data (Intent) as you received it. * @return Returns true if the result was related to a purchase flow and was handled; * false if the result was not related to a purchase, in which case you should * handle it normally. */ public boolean handleActivityResult(int requestCode, int resultCode, Intent data) { IabResult result; if (requestCode != mRequestCode) return false; checkNotDisposed(); checkSetupDone("handleActivityResult"); // end of async purchase operation that started on launchPurchaseFlow flagEndAsync(); if (data == null) { logError("Null data in IAB activity result."); result = new IabResult(IABHELPER_BAD_RESPONSE, "Null data in IAB result"); if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); return true; } int responseCode = getResponseCodeFromIntent(data); String purchaseData = data.getStringExtra(RESPONSE_INAPP_PURCHASE_DATA); String dataSignature = data.getStringExtra(RESPONSE_INAPP_SIGNATURE); if (resultCode == Activity.RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) { logDebug("Successful resultcode from purchase activity."); logDebug("Purchase data: " + purchaseData); logDebug("Data signature: " + dataSignature); logDebug("Extras: " + data.getExtras()); logDebug("Expected item type: " + mPurchasingItemType); if (purchaseData == null || dataSignature == null) { logError("BUG: either purchaseData or dataSignature is null."); logDebug("Extras: " + data.getExtras().toString()); result = new IabResult(IABHELPER_UNKNOWN_ERROR, "IAB returned null purchaseData or dataSignature"); if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); return true; } Purchase purchase = null; try { purchase = new Purchase(mPurchasingItemType, purchaseData, dataSignature); String sku = purchase.getSku(); // Verify signature if (!Security.verifyPurchase(mSignatureBase64, purchaseData, dataSignature)) { logError("Purchase signature verification FAILED for sku " + sku); result = new IabResult(IABHELPER_VERIFICATION_FAILED, "Signature verification failed for sku " + sku); if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, purchase); return true; } logDebug("Purchase signature successfully verified."); } catch (JSONException e) { logError("Failed to parse purchase data."); e.printStackTrace(); result = new IabResult(IABHELPER_BAD_RESPONSE, "Failed to parse purchase data."); if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); return true; } if (mPurchaseListener != null) { mPurchaseListener.onIabPurchaseFinished(new IabResult(BILLING_RESPONSE_RESULT_OK, "Success"), purchase); } } else if (resultCode == Activity.RESULT_OK) { // result code was OK, but in-app billing response was not OK. logDebug("Result code was OK but in-app billing response was not OK: " + getResponseDesc(responseCode)); if (mPurchaseListener != null) { result = new IabResult(responseCode, "Problem purchashing item."); mPurchaseListener.onIabPurchaseFinished(result, null); } } else if (resultCode == Activity.RESULT_CANCELED) { logDebug("Purchase canceled - Response: " + getResponseDesc(responseCode)); result = new IabResult(IABHELPER_USER_CANCELLED, "User canceled."); if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); } else { logError("Purchase failed. Result code: " + Integer.toString(resultCode) + ". Response: " + getResponseDesc(responseCode)); result = new IabResult(IABHELPER_UNKNOWN_PURCHASE_RESPONSE, "Unknown purchase response."); if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); } return true; } public Inventory queryInventory() throws IabException { return queryInventory(false, null, null); } /** * Queries the inventory. This will query all owned items from the server, as well as * information on additional skus, if specified. This method may block or take long to execute. * Do not call from a UI thread. For that, use the non-blocking version {@link #queryInventoryAsync}. * * @param querySkuDetails if true, SKU details (price, description, etc) will be queried as well * as purchase information. * @param moreItemSkus additional PRODUCT skus to query information on, regardless of ownership. * Ignored if null or if querySkuDetails is false. * @param moreSubsSkus additional SUBSCRIPTIONS skus to query information on, regardless of ownership. * Ignored if null or if querySkuDetails is false. * @throws IabException if a problem occurs while refreshing the inventory. */ public Inventory queryInventory(boolean querySkuDetails, List moreItemSkus, List moreSubsSkus) throws IabException { checkNotDisposed(); checkSetupDone("queryInventory"); try { Inventory inv = new Inventory(); int r = queryPurchases(inv, ITEM_TYPE_INAPP); if (r != BILLING_RESPONSE_RESULT_OK) { throw new IabException(r, "Error refreshing inventory (querying owned items)."); } if (querySkuDetails) { r = querySkuDetails(ITEM_TYPE_INAPP, inv, moreItemSkus); if (r != BILLING_RESPONSE_RESULT_OK) { throw new IabException(r, "Error refreshing inventory (querying prices of items)."); } } // if subscriptions are supported, then also query for subscriptions if (mSubscriptionsSupported) { r = queryPurchases(inv, ITEM_TYPE_SUBS); if (r != BILLING_RESPONSE_RESULT_OK) { throw new IabException(r, "Error refreshing inventory (querying owned subscriptions)."); } if (querySkuDetails) { r = querySkuDetails(ITEM_TYPE_SUBS, inv, moreSubsSkus); if (r != BILLING_RESPONSE_RESULT_OK) { throw new IabException(r, "Error refreshing inventory (querying prices of subscriptions)."); } } } return inv; } catch (RemoteException e) { throw new IabException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while refreshing inventory.", e); } catch (JSONException e) { throw new IabException(IABHELPER_BAD_RESPONSE, "Error parsing JSON response while refreshing inventory.", e); } } /** * DonationCallbacks that notifies when an inventory query operation completes. */ public interface QueryInventoryFinishedListener { /** * Called to notify that an inventory query operation completed. * * @param result The result of the operation. * @param inv The inventory. */ void onQueryInventoryFinished(IabResult result, Inventory inv); } /** * Asynchronous wrapper for inventory query. This will perform an inventory * query as described in {@link #queryInventory}, but will do so asynchronously * and call back the specified listener upon completion. This method is safe to * call from a UI thread. * * @param querySkuDetails as in {@link #queryInventory} * @param moreItemSkus as in {@link #queryInventory} * @param moreSubsSkus as in {@link #queryInventory} * @param listener The listener to notify when the refresh operation completes. */ public void queryInventoryAsync(final boolean querySkuDetails, final List moreItemSkus, final List moreSubsSkus, final QueryInventoryFinishedListener listener) throws IabAsyncInProgressException { final Handler handler = new Handler(); checkNotDisposed(); checkSetupDone("queryInventory"); flagStartAsync("refresh inventory"); (new Thread(new Runnable() { public void run() { IabResult result = new IabResult(BILLING_RESPONSE_RESULT_OK, "Inventory refresh successful."); Inventory inv = null; try { inv = queryInventory(querySkuDetails, moreItemSkus, moreSubsSkus); } catch (IabException ex) { result = ex.getResult(); } flagEndAsync(); final IabResult result_f = result; final Inventory inv_f = inv; if (!mDisposed && listener != null) { handler.post(new Runnable() { public void run() { listener.onQueryInventoryFinished(result_f, inv_f); } }); } } })).start(); } public void queryInventoryAsync(QueryInventoryFinishedListener listener) throws IabAsyncInProgressException{ queryInventoryAsync(false, null, null, listener); } /** * Consumes a given in-app product. Consuming can only be done on an item * that's owned, and as a result of consumption, the user will no longer own it. * This method may block or take long to return. Do not call from the UI thread. * For that, see {@link #consumeAsync}. * * @param itemInfo The PurchaseInfo that represents the item to consume. * @throws IabException if there is a problem during consumption. */ void consume(Purchase itemInfo) throws IabException { checkNotDisposed(); checkSetupDone("consume"); if (!itemInfo.mItemType.equals(ITEM_TYPE_INAPP)) { throw new IabException(IABHELPER_INVALID_CONSUMPTION, "Items of type '" + itemInfo.mItemType + "' can't be consumed."); } try { String token = itemInfo.getToken(); String sku = itemInfo.getSku(); if (token == null || token.equals("")) { logError("Can't consume "+ sku + ". No token."); throw new IabException(IABHELPER_MISSING_TOKEN, "PurchaseInfo is missing token for sku: " + sku + " " + itemInfo); } logDebug("Consuming sku: " + sku + ", token: " + token); int response = mService.consumePurchase(3, mContext.getPackageName(), token); if (response == BILLING_RESPONSE_RESULT_OK) { logDebug("Successfully consumed sku: " + sku); } else { logDebug("Error consuming consuming sku " + sku + ". " + getResponseDesc(response)); throw new IabException(response, "Error consuming sku " + sku); } } catch (RemoteException e) { throw new IabException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while consuming. PurchaseInfo: " + itemInfo, e); } } /** * Callback that notifies when a consumption operation finishes. */ public interface OnConsumeFinishedListener { /** * Called to notify that a consumption has finished. * * @param purchase The purchase that was (or was to be) consumed. * @param result The result of the consumption operation. */ void onConsumeFinished(Purchase purchase, IabResult result); } /** * Callback that notifies when a multi-item consumption operation finishes. */ public interface OnConsumeMultiFinishedListener { /** * Called to notify that a consumption of multiple items has finished. * * @param purchases The purchases that were (or were to be) consumed. * @param results The results of each consumption operation, corresponding to each * sku. */ void onConsumeMultiFinished(List purchases, List results); } /** * Asynchronous wrapper to item consumption. Works like {@link #consume}, but * performs the consumption in the background and notifies completion through * the provided listener. This method is safe to call from a UI thread. * * @param purchase The purchase to be consumed. * @param listener The listener to notify when the consumption operation finishes. */ public void consumeAsync(Purchase purchase, OnConsumeFinishedListener listener) throws IabAsyncInProgressException { checkNotDisposed(); checkSetupDone("consume"); List purchases = new ArrayList(); purchases.add(purchase); consumeAsyncInternal(purchases, listener, null); } /** * Same as {@link #consumeAsync}, but for multiple items at once. * @param purchases The list of PurchaseInfo objects representing the purchases to consume. * @param listener The listener to notify when the consumption operation finishes. */ public void consumeAsync(List purchases, OnConsumeMultiFinishedListener listener) throws IabAsyncInProgressException { checkNotDisposed(); checkSetupDone("consume"); consumeAsyncInternal(purchases, null, listener); } /** * Returns a human-readable description for the given response code. * * @param code The response code * @return A human-readable string explaining the result code. * It also includes the result code numerically. */ public static String getResponseDesc(int code) { String[] iab_msgs = ("0:OK/1:User Canceled/2:Unknown/" + "3:Billing Unavailable/4:Item unavailable/" + "5:Developer Error/6:Error/7:Item Already Owned/" + "8:Item not owned").split("/"); String[] iabhelper_msgs = ("0:OK/-1001:Remote exception during initialization/" + "-1002:Bad response received/" + "-1003:Purchase signature verification failed/" + "-1004:Send intent failed/" + "-1005:User cancelled/" + "-1006:Unknown purchase response/" + "-1007:Missing token/" + "-1008:Unknown error/" + "-1009:Subscriptions not available/" + "-1010:Invalid consumption attempt").split("/"); if (code <= IABHELPER_ERROR_BASE) { int index = IABHELPER_ERROR_BASE - code; if (index >= 0 && index < iabhelper_msgs.length) return iabhelper_msgs[index]; else return String.valueOf(code) + ":Unknown IAB Helper Error"; } else if (code < 0 || code >= iab_msgs.length) return String.valueOf(code) + ":Unknown"; else return iab_msgs[code]; } // Checks that setup was done; if not, throws an exception. void checkSetupDone(String operation) { if (!mSetupDone) { logError("Illegal state for operation (" + operation + "): IAB helper is not set up."); throw new IllegalStateException("IAB helper is not set up. Can't perform operation: " + operation); } } // Workaround to bug where sometimes response codes come as Long instead of Integer int getResponseCodeFromBundle(Bundle b) { Object o = b.get(RESPONSE_CODE); if (o == null) { logDebug("Bundle with null response code, assuming OK (known issue)"); return BILLING_RESPONSE_RESULT_OK; } else if (o instanceof Integer) return ((Integer)o).intValue(); else if (o instanceof Long) return (int)((Long)o).longValue(); else { logError("Unexpected type for bundle response code."); logError(o.getClass().getName()); throw new RuntimeException("Unexpected type for bundle response code: " + o.getClass().getName()); } } // Workaround to bug where sometimes response codes come as Long instead of Integer int getResponseCodeFromIntent(Intent i) { Object o = i.getExtras().get(RESPONSE_CODE); if (o == null) { logError("Intent with no response code, assuming OK (known issue)"); return BILLING_RESPONSE_RESULT_OK; } else if (o instanceof Integer) return ((Integer)o).intValue(); else if (o instanceof Long) return (int)((Long)o).longValue(); else { logError("Unexpected type for intent response code."); logError(o.getClass().getName()); throw new RuntimeException("Unexpected type for intent response code: " + o.getClass().getName()); } } void flagStartAsync(String operation) throws IabAsyncInProgressException { synchronized (mAsyncInProgressLock) { if (mAsyncInProgress) { throw new IabAsyncInProgressException("Can't start async operation (" + operation + ") because another async operation (" + mAsyncOperation + ") is in progress."); } mAsyncOperation = operation; mAsyncInProgress = true; logDebug("Starting async operation: " + operation); } } void flagEndAsync() { synchronized (mAsyncInProgressLock) { logDebug("Ending async operation: " + mAsyncOperation); mAsyncOperation = ""; mAsyncInProgress = false; if (mDisposeAfterAsync) { try { dispose(); } catch (IabAsyncInProgressException e) { // Should not be thrown, because we reset mAsyncInProgress immediately before // calling dispose(). } } } } /** * Exception thrown when the requested operation cannot be started because an async operation * is still in progress. */ public static class IabAsyncInProgressException extends Exception { public IabAsyncInProgressException(String message) { super(message); } } int queryPurchases(Inventory inv, String itemType) throws JSONException, RemoteException { // Query purchases logDebug("Querying owned items, item type: " + itemType); logDebug("Package name: " + mContext.getPackageName()); boolean verificationFailed = false; String continueToken = null; do { logDebug("Calling getPurchases with continuation token: " + continueToken); Bundle ownedItems = mService.getPurchases(3, mContext.getPackageName(), itemType, continueToken); int response = getResponseCodeFromBundle(ownedItems); logDebug("Owned items response: " + String.valueOf(response)); if (response != BILLING_RESPONSE_RESULT_OK) { logDebug("getPurchases() failed: " + getResponseDesc(response)); return response; } if (!ownedItems.containsKey(RESPONSE_INAPP_ITEM_LIST) || !ownedItems.containsKey(RESPONSE_INAPP_PURCHASE_DATA_LIST) || !ownedItems.containsKey(RESPONSE_INAPP_SIGNATURE_LIST)) { logError("Bundle returned from getPurchases() doesn't contain required fields."); return IABHELPER_BAD_RESPONSE; } ArrayList ownedSkus = ownedItems.getStringArrayList( RESPONSE_INAPP_ITEM_LIST); ArrayList purchaseDataList = ownedItems.getStringArrayList( RESPONSE_INAPP_PURCHASE_DATA_LIST); ArrayList signatureList = ownedItems.getStringArrayList( RESPONSE_INAPP_SIGNATURE_LIST); for (int i = 0; i < purchaseDataList.size(); ++i) { String purchaseData = purchaseDataList.get(i); String signature = signatureList.get(i); String sku = ownedSkus.get(i); if (Security.verifyPurchase(mSignatureBase64, purchaseData, signature)) { logDebug("Sku is owned: " + sku); Purchase purchase = new Purchase(itemType, purchaseData, signature); if (TextUtils.isEmpty(purchase.getToken())) { logWarn("BUG: empty/null token!"); logDebug("Purchase data: " + purchaseData); } // Record ownership and token inv.addPurchase(purchase); } else { logWarn("Purchase signature verification **FAILED**. Not adding item."); logDebug(" Purchase data: " + purchaseData); logDebug(" Signature: " + signature); verificationFailed = true; } } continueToken = ownedItems.getString(INAPP_CONTINUATION_TOKEN); logDebug("Continuation token: " + continueToken); } while (!TextUtils.isEmpty(continueToken)); return verificationFailed ? IABHELPER_VERIFICATION_FAILED : BILLING_RESPONSE_RESULT_OK; } int querySkuDetails(String itemType, Inventory inv, List moreSkus) throws RemoteException, JSONException { logDebug("Querying SKU details."); ArrayList skuList = new ArrayList(); skuList.addAll(inv.getAllOwnedSkus(itemType)); if (moreSkus != null) { for (String sku : moreSkus) { if (!skuList.contains(sku)) { skuList.add(sku); } } } if (skuList.size() == 0) { logDebug("queryPrices: nothing to do because there are no SKUs."); return BILLING_RESPONSE_RESULT_OK; } // Split the sku list in blocks of no more than 20 elements. ArrayList> packs = new ArrayList>(); ArrayList tempList; int n = skuList.size() / 20; int mod = skuList.size() % 20; for (int i = 0; i < n; i++) { tempList = new ArrayList(); for (String s : skuList.subList(i * 20, i * 20 + 20)) { tempList.add(s); } packs.add(tempList); } if (mod != 0) { tempList = new ArrayList(); for (String s : skuList.subList(n * 20, n * 20 + mod)) { tempList.add(s); } packs.add(tempList); } for (ArrayList skuPartList : packs) { Bundle querySkus = new Bundle(); querySkus.putStringArrayList(GET_SKU_DETAILS_ITEM_LIST, skuPartList); Bundle skuDetails = mService.getSkuDetails(3, mContext.getPackageName(), itemType, querySkus); if (!skuDetails.containsKey(RESPONSE_GET_SKU_DETAILS_LIST)) { int response = getResponseCodeFromBundle(skuDetails); if (response != BILLING_RESPONSE_RESULT_OK) { logDebug("getSkuDetails() failed: " + getResponseDesc(response)); return response; } else { logError("getSkuDetails() returned a bundle with neither an error nor a detail list."); return IABHELPER_BAD_RESPONSE; } } ArrayList responseList = skuDetails.getStringArrayList( RESPONSE_GET_SKU_DETAILS_LIST); for (String thisResponse : responseList) { SkuDetails d = new SkuDetails(itemType, thisResponse); logDebug("Got sku details: " + d); inv.addSkuDetails(d); } } return BILLING_RESPONSE_RESULT_OK; } void consumeAsyncInternal(final List purchases, final OnConsumeFinishedListener singleListener, final OnConsumeMultiFinishedListener multiListener) throws IabAsyncInProgressException { final Handler handler = new Handler(); flagStartAsync("consume"); (new Thread(new Runnable() { public void run() { final List results = new ArrayList(); for (Purchase purchase : purchases) { try { consume(purchase); results.add(new IabResult(BILLING_RESPONSE_RESULT_OK, "Successful consume of sku " + purchase.getSku())); } catch (IabException ex) { results.add(ex.getResult()); } } flagEndAsync(); if (!mDisposed && singleListener != null) { handler.post(new Runnable() { public void run() { singleListener.onConsumeFinished(purchases.get(0), results.get(0)); } }); } if (!mDisposed && multiListener != null) { handler.post(new Runnable() { public void run() { multiListener.onConsumeMultiFinished(purchases, results); } }); } } })).start(); } void logDebug(String msg) { if (mDebugLog) Log.d(mDebugTag, msg); } void logError(String msg) { Log.e(mDebugTag, "In-app billing error: " + msg); } void logWarn(String msg) { Log.w(mDebugTag, "In-app billing warning: " + msg); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/IabResult.java ================================================ /* Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; /** * Represents the result of an in-app billing operation. * A result is composed of a response code (an integer) and possibly a * message (String). You can get those by calling * {@link #getResponse} and {@link #getMessage()}, respectively. You * can also inquire whether a result is a success or a failure by * calling {@link #isSuccess()} and {@link #isFailure()}. */ public class IabResult { int mResponse; String mMessage; public IabResult(int response, String message) { mResponse = response; if (message == null || message.trim().length() == 0) { mMessage = IabHelper.getResponseDesc(response); } else { mMessage = message + " (response: " + IabHelper.getResponseDesc(response) + ")"; } } public int getResponse() { return mResponse; } public String getMessage() { return mMessage; } public boolean isSuccess() { return mResponse == IabHelper.BILLING_RESPONSE_RESULT_OK; } public boolean isFailure() { return !isSuccess(); } public String toString() { return "IabResult: " + getMessage(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/Inventory.java ================================================ /* Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Represents a block of information about in-app items. * An Inventory is returned by such methods as {@link IabHelper#queryInventory}. */ public class Inventory { Map mSkuMap = new HashMap(); Map mPurchaseMap = new HashMap(); Inventory() { } /** Returns the listing details for an in-app product. */ public SkuDetails getSkuDetails(String sku) { return mSkuMap.get(sku); } /** Returns purchase information for a given product, or null if there is no purchase. */ public Purchase getPurchase(String sku) { return mPurchaseMap.get(sku); } /** Returns whether or not there exists a purchase of the given product. */ public boolean hasPurchase(String sku) { return mPurchaseMap.containsKey(sku); } /** Return whether or not details about the given product are available. */ public boolean hasDetails(String sku) { return mSkuMap.containsKey(sku); } /** * Erase a purchase (locally) from the inventory, given its product ID. This just * modifies the Inventory object locally and has no effect on the server! This is * useful when you have an existing Inventory object which you know to be up to date, * and you have just consumed an item successfully, which means that erasing its * purchase data from the Inventory you already have is quicker than querying for * a new Inventory. */ public void erasePurchase(String sku) { if (mPurchaseMap.containsKey(sku)) mPurchaseMap.remove(sku); } /** Returns a list of all owned product IDs. */ List getAllOwnedSkus() { return new ArrayList(mPurchaseMap.keySet()); } /** Returns a list of all owned product IDs of a given type */ List getAllOwnedSkus(String itemType) { List result = new ArrayList(); for (Purchase p : mPurchaseMap.values()) { if (p.getItemType().equals(itemType)) result.add(p.getSku()); } return result; } /** Returns a list of all purchases. */ List getAllPurchases() { return new ArrayList(mPurchaseMap.values()); } void addSkuDetails(SkuDetails d) { mSkuMap.put(d.getSku(), d); } void addPurchase(Purchase p) { mPurchaseMap.put(p.getSku(), p); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/Purchase.java ================================================ /* Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; import org.json.JSONException; import org.json.JSONObject; /** * Represents an in-app billing purchase. */ public class Purchase { String mItemType; // ITEM_TYPE_INAPP or ITEM_TYPE_SUBS String mOrderId; String mPackageName; String mSku; long mPurchaseTime; int mPurchaseState; String mDeveloperPayload; String mToken; String mOriginalJson; String mSignature; boolean mIsAutoRenewing; public Purchase(String itemType, String jsonPurchaseInfo, String signature) throws JSONException { mItemType = itemType; mOriginalJson = jsonPurchaseInfo; JSONObject o = new JSONObject(mOriginalJson); mOrderId = o.optString("orderId"); mPackageName = o.optString("packageName"); mSku = o.optString("productId"); mPurchaseTime = o.optLong("purchaseTime"); mPurchaseState = o.optInt("purchaseState"); mDeveloperPayload = o.optString("developerPayload"); mToken = o.optString("token", o.optString("purchaseToken")); mIsAutoRenewing = o.optBoolean("autoRenewing"); mSignature = signature; } public String getItemType() { return mItemType; } public String getOrderId() { return mOrderId; } public String getPackageName() { return mPackageName; } public String getSku() { return mSku; } public long getPurchaseTime() { return mPurchaseTime; } public int getPurchaseState() { return mPurchaseState; } public String getDeveloperPayload() { return mDeveloperPayload; } public String getToken() { return mToken; } public String getOriginalJson() { return mOriginalJson; } public String getSignature() { return mSignature; } public boolean isAutoRenewing() { return mIsAutoRenewing; } @Override public String toString() { return "PurchaseInfo(type:" + mItemType + "):" + mOriginalJson; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/Security.java ================================================ /* Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; import android.text.TextUtils; import android.util.Base64; import android.util.Log; import java.security.InvalidKeyException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.Signature; import java.security.SignatureException; import java.security.spec.InvalidKeySpecException; import java.security.spec.X509EncodedKeySpec; /** * Security-related methods. For a secure implementation, all of this code * should be implemented on a server that communicates with the * application on the device. For the sake of simplicity and clarity of this * example, this code is included here and is executed on the device. If you * must verify the purchases on the phone, you should obfuscate this code to * make it harder for an attacker to replace the code with stubs that treat all * purchases as verified. */ @SuppressWarnings("all") public class Security { private static final String TAG = "IABUtil/Security"; private static final String KEY_FACTORY_ALGORITHM = "RSA"; private static final String SIGNATURE_ALGORITHM = "SHA1withRSA"; /** * Verifies that the data was signed with the given signature, and returns * the verified purchase. The data is in JSON format and signed * with a private key. The data also contains the {@link PurchaseState} * and product ID of the purchase. * @param base64PublicKey the base64-encoded public key to use for verifying. * @param signedData the signed JSON string (signed, not encrypted) * @param signature the signature for the data, signed with the private key */ public static boolean verifyPurchase(String base64PublicKey, String signedData, String signature) { if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) || TextUtils.isEmpty(signature)) { Log.e(TAG, "Purchase verification failed: missing data."); return false; } PublicKey key = Security.generatePublicKey(base64PublicKey); return Security.verify(key, signedData, signature); } /** * Generates a PublicKey instance from a string containing the * Base64-encoded public key. * * @param encodedPublicKey Base64-encoded public key * @throws IllegalArgumentException if encodedPublicKey is invalid */ public static PublicKey generatePublicKey(String encodedPublicKey) { try { byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT); KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM); return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey)); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } catch (InvalidKeySpecException e) { Log.e(TAG, "Invalid key specification."); throw new IllegalArgumentException(e); } } /** * Verifies that the signature from the server matches the computed * signature on the data. Returns true if the data is correctly signed. * * @param publicKey public key associated with the developer account * @param signedData signed data from server * @param signature server signature * @return true if the data and signature match */ public static boolean verify(PublicKey publicKey, String signedData, String signature) { byte[] signatureBytes; try { signatureBytes = Base64.decode(signature, Base64.DEFAULT); } catch (IllegalArgumentException e) { Log.e(TAG, "Base64 decoding failed."); return false; } try { Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM); sig.initVerify(publicKey); sig.update(signedData.getBytes()); if (!sig.verify(signatureBytes)) { Log.e(TAG, "Signature verification failed."); return false; } return true; } catch (NoSuchAlgorithmException e) { Log.e(TAG, "NoSuchAlgorithmException."); } catch (InvalidKeyException e) { Log.e(TAG, "Invalid key specification."); } catch (SignatureException e) { Log.e(TAG, "Signature exception."); } return false; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/donate/util/SkuDetails.java ================================================ /* Copyright (c) 2012 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.alexstyl.specialdates.donate.util; import org.json.JSONException; import org.json.JSONObject; /** * Represents an in-app product's listing details. */ public class SkuDetails { private final String mItemType; private final String mSku; private final String mType; private final String mPrice; private final long mPriceAmountMicros; private final String mPriceCurrencyCode; private final String mTitle; private final String mDescription; private final String mJson; public SkuDetails(String jsonSkuDetails) throws JSONException { this(IabHelper.ITEM_TYPE_INAPP, jsonSkuDetails); } public SkuDetails(String itemType, String jsonSkuDetails) throws JSONException { mItemType = itemType; mJson = jsonSkuDetails; JSONObject o = new JSONObject(mJson); mSku = o.optString("productId"); mType = o.optString("type"); mPrice = o.optString("price"); mPriceAmountMicros = o.optLong("price_amount_micros"); mPriceCurrencyCode = o.optString("price_currency_code"); mTitle = o.optString("title"); mDescription = o.optString("description"); } public String getSku() { return mSku; } public String getType() { return mType; } public String getPrice() { return mPrice; } public long getPriceAmountMicros() { return mPriceAmountMicros; } public String getPriceCurrencyCode() { return mPriceCurrencyCode; } public String getTitle() { return mTitle; } public String getDescription() { return mDescription; } @Override public String toString() { return "SkuDetails:" + mJson; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/ContactsObserver.java ================================================ package com.alexstyl.specialdates.events; import android.content.ContentResolver; import android.database.ContentObserver; import android.net.Uri; import android.os.Handler; import android.provider.ContactsContract; import com.alexstyl.specialdates.EventsUpdateTrigger; public final class ContactsObserver extends ContentObserver implements EventsUpdateTrigger { private static final Uri URI = ContactsContract.Contacts.CONTENT_URI; private final ContentResolver resolver; private Callback callback; public ContactsObserver(ContentResolver resolver) { super(new Handler()); this.resolver = resolver; } @Override public void startObserving(Callback callback) { this.callback = callback; resolver.registerContentObserver(URI, false, this); } @Override public void onChange(boolean selfChange) { super.onChange(selfChange); callback.onMonitorTriggered(); } @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); callback.onMonitorTriggered(); } @Override public void stopObserving() { resolver.unregisterContentObserver(this); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/PreferenceChangedEventsUpdateTrigger.java ================================================ package com.alexstyl.specialdates.events; import android.content.SharedPreferences; import android.content.res.Resources; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.EventsUpdateTrigger; import java.util.ArrayList; import java.util.List; public final class PreferenceChangedEventsUpdateTrigger implements EventsUpdateTrigger { private final EasyPreferences preferences; private final List keys; private SharedPreferences.OnSharedPreferenceChangeListener listener; public PreferenceChangedEventsUpdateTrigger(EasyPreferences preferences, Resources strings, int firstKeys, int... keys) { this.preferences = preferences; this.keys = new ArrayList<>(keys.length + 1); this.keys.add(strings.getString(firstKeys)); for (int key : keys) { this.keys.add(strings.getString(key)); } } @Override public void startObserving(final Callback callback) { listener = new SharedPreferences.OnSharedPreferenceChangeListener() { @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (isAKeyIcareAbout(key)) { callback.onMonitorTriggered(); } } }; preferences.addOnPreferenceChangedListener(listener); } private boolean isAKeyIcareAbout(String key) { return keys.contains(key); } @Override public void stopObserving() { preferences.removeOnPreferenceChagnedListener(listener); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/bankholidays/BankHolidaysModule.java ================================================ package com.alexstyl.specialdates.events.bankholidays; import android.content.Context; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.events.namedays.calendar.OrthodoxEasterCalculator; import dagger.Module; import dagger.Provides; @Module public class BankHolidaysModule { @Provides BankHolidaysUserSettings userSettings(Context context) { EasyPreferences preferences = EasyPreferences.createForDefaultPreferences(context); return new BankHolidaysPreferences(preferences); } @Provides GreekBankHolidaysCalculator greekBankHolidaysCalculator(OrthodoxEasterCalculator calculator) { return new GreekBankHolidaysCalculator(calculator); } @Provides BankHolidayProvider provider(GreekBankHolidaysCalculator bankHolidaysCalculator) { return new BankHolidayProvider(bankHolidaysCalculator); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/bankholidays/BankHolidaysPreferences.java ================================================ package com.alexstyl.specialdates.events.bankholidays; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.R; public class BankHolidaysPreferences implements BankHolidaysUserSettings { private final EasyPreferences preferences; BankHolidaysPreferences(EasyPreferences preferences) { this.preferences = preferences; } @Override public boolean isEnabled() { return preferences.getBoolean(R.string.key_enable_bank_holidays, R.bool.isBankholidaysSupported); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/database/ContactColumns.java ================================================ package com.alexstyl.specialdates.events.database; @SuppressWarnings("all") interface ContactColumns { String CONTACT_ID = "contact_id"; String DISPLAY_NAME = "display_name"; } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/database/DatabaseContract.java ================================================ package com.alexstyl.specialdates.events.database; import android.provider.BaseColumns; public final class DatabaseContract { public static final class AnnualEventsContract implements BaseColumns, ContactColumns, EventColumns { public static final String TABLE_NAME = "annual_events"; } private DatabaseContract() { // hide this } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/database/EventSQLiteOpenHelper.kt ================================================ package com.alexstyl.specialdates.events.database import android.content.Context import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.provider.BaseColumns._ID import com.alexstyl.specialdates.events.database.ContactColumns.CONTACT_ID import com.alexstyl.specialdates.events.database.ContactColumns.DISPLAY_NAME import com.alexstyl.specialdates.events.database.DatabaseContract.AnnualEventsContract.TABLE_NAME import com.alexstyl.specialdates.events.database.EventColumns.DATE import com.alexstyl.specialdates.events.database.EventColumns.DEVICE_EVENT_ID import com.alexstyl.specialdates.events.database.EventColumns.EVENT_TYPE import com.alexstyl.specialdates.events.database.EventColumns.SOURCE import com.alexstyl.specialdates.events.database.EventColumns.VISIBLE class EventSQLiteOpenHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { db.execSQL("DROP TABLE IF EXISTS dynamic_events;") db.execSQL("DROP TABLE IF EXISTS annual_events;") onCreate(db) } override fun onCreate(db: SQLiteDatabase) { db.execSQL(("CREATE TABLE $TABLE_NAME (" + "$_ID INTEGER NOT NULL, " + "$DISPLAY_NAME TEXT NOT NULL, " + "$DEVICE_EVENT_ID INTEGER NOT NULL, " + "$CONTACT_ID INTEGER NOT NULL, " + "$DATE TEXT NOT NULL, " + "$EVENT_TYPE INTEGER NOT NULL, " + "$SOURCE INTEGER NOT NULL, " + "$VISIBLE INTEGER NOT NULL, " + "PRIMARY KEY ($_ID)" + ");")) } companion object { const val DATABASE_VERSION = 5 private const val DATABASE_NAME = "events.db" } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/NamedayModule.java ================================================ package com.alexstyl.specialdates.events.namedays; import android.content.Context; import android.content.res.Resources; import com.alexstyl.specialdates.date.Date; import com.alexstyl.specialdates.events.namedays.calendar.NamedayCalendar; import com.alexstyl.specialdates.events.namedays.calendar.OrthodoxEasterCalculator; import com.alexstyl.specialdates.events.namedays.calendar.resource.AndroidJSONResourceLoader; import com.alexstyl.specialdates.events.namedays.calendar.resource.NamedayCalendarProvider; import com.alexstyl.specialdates.events.namedays.calendar.resource.NamedayJSONProvider; import com.alexstyl.specialdates.events.namedays.calendar.resource.RomanianEasterSpecialCalculator; import com.alexstyl.specialdates.events.namedays.calendar.resource.SpecialNamedaysHandlerFactory; import javax.inject.Singleton; import dagger.Module; import dagger.Provides; @Module @Singleton public class NamedayModule { @Provides NamedayJSONProvider namedayJSONProvider(Resources resources) { return new NamedayJSONProvider(new AndroidJSONResourceLoader(resources)); } @Provides RomanianEasterSpecialCalculator romanianEasterSpecialCalculator(OrthodoxEasterCalculator calculator) { return new RomanianEasterSpecialCalculator(calculator); } @Provides OrthodoxEasterCalculator calculator() { return new OrthodoxEasterCalculator(); } @Provides SpecialNamedaysHandlerFactory handlerFactory(OrthodoxEasterCalculator easterCalculator, RomanianEasterSpecialCalculator romanianEasterCalculator) { return new SpecialNamedaysHandlerFactory(easterCalculator, romanianEasterCalculator); } @Provides @Singleton NamedayCalendarProvider provider(OrthodoxEasterCalculator easterCalculator, NamedayJSONProvider namedayJSONProvider, SpecialNamedaysHandlerFactory factory) { return new NamedayCalendarProvider( namedayJSONProvider, factory ); } @Provides NamedayUserSettings userSettings(Context context) { return new NamedayPreferences(context); } @Provides NamedayCalendar calendar(NamedayUserSettings settings, NamedayCalendarProvider namedayCalendarProvider) { NamedayLocale selectedLanguage = settings.getSelectedLanguage(); int year = Date.Companion.getCURRENT_YEAR(); return namedayCalendarProvider.loadNamedayCalendarForLocale(selectedLanguage, year); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/NamedayPreferences.java ================================================ package com.alexstyl.specialdates.events.namedays; import android.content.Context; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.R; public final class NamedayPreferences implements NamedayUserSettings { private static final String DEFAULT_LOCALE = NamedayLocale.GREEK.getCountryCode(); private final boolean enabledByDefault; private final EasyPreferences preferences; NamedayPreferences(Context context) { this.preferences = EasyPreferences.createForDefaultPreferences(context); this.enabledByDefault = shouldNamedaysBeEnabledByDefault(context); } @Override public void setSelectedLanguage(String language) { preferences.setString(R.string.key_nameday_lang, language); } @Override public NamedayLocale getSelectedLanguage() { String lang = preferences.getString(R.string.key_nameday_lang, DEFAULT_LOCALE); return NamedayLocale.from(lang); } @Override public boolean isEnabled() { return preferences.getBoolean(R.string.key_enable_namedays, enabledByDefault); } @Override public boolean isEnabledForContactsOnly() { return preferences.getBoolean(R.string.key_namedays_contacts_only, false); } @Override public void setEnabledForContactsOnly(boolean onlyForContacts) { preferences.setBoolean(R.string.key_namedays_contacts_only, onlyForContacts); } @Override public boolean shouldLookupAllNames() { return preferences.getBoolean(R.string.key_namedays_full_name, false); } private static boolean shouldNamedaysBeEnabledByDefault(Context context) { return context.getResources().getBoolean(R.bool.isNamedaySupported); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/AndroidNamedaysOnADayView.kt ================================================ package com.alexstyl.specialdates.events.namedays.activity class AndroidNamedaysOnADayView(private val screenAdapter: NamedaysScreenAdapter) : NamedaysOnADayView { override fun displayNamedays(viewModels: List) { screenAdapter.display(viewModels) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/CelebratingContactViewHolder.kt ================================================ package com.alexstyl.specialdates.events.namedays.activity import android.view.View import android.widget.TextView import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.images.ImageLoader import com.alexstyl.specialdates.ui.widget.ColorImageView class CelebratingContactViewHolder(view: View, private val imageLoader: ImageLoader, private val avatarView: ColorImageView, private val contactNameView: TextView) : NamedayScreenViewHolder(view) { override fun bind(viewModel: CelebratingContactViewModel, onContactClicked: (Contact) -> Unit) { val contact = viewModel.contact contactNameView.text = contact.displayName.toString() avatarView.setLetter(viewModel.letter) avatarView.setCircleColorVariant(viewModel.colorVariant) imageLoader.load(contact.imagePath) .asCircle() .into(avatarView.imageView) itemView.setOnClickListener { onContactClicked(contact) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NameViewHolder.kt ================================================ package com.alexstyl.specialdates.events.namedays.activity import android.view.View import android.widget.TextView import com.alexstyl.specialdates.contact.Contact class NameViewHolder(view: View, private val nameView: TextView) : NamedayScreenViewHolder(view) { override fun bind(viewModel: NamedaysViewModel, onContactClicked: (Contact) -> Unit) { nameView.text = viewModel.name } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NamedayScreenViewHolder.kt ================================================ package com.alexstyl.specialdates.events.namedays.activity import android.support.v7.widget.RecyclerView import android.view.View import com.alexstyl.specialdates.contact.Contact abstract class NamedayScreenViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { abstract fun bind(viewModel: T, onContactClicked: (Contact) -> Unit) } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NamedaysInADayModule.java ================================================ package com.alexstyl.specialdates.events.namedays.activity; import com.alexstyl.specialdates.contact.ContactsProvider; import com.alexstyl.specialdates.events.namedays.NamedayUserSettings; import com.alexstyl.specialdates.events.namedays.calendar.NamedayCalendar; import com.alexstyl.specialdates.ui.widget.AndroidLetterPainter; import dagger.Module; import dagger.Provides; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; @Module public class NamedaysInADayModule { @Provides NamedaysViewModelFactory viewModelFactory(AndroidLetterPainter letterPainter) { return new NamedaysViewModelFactory(letterPainter); } @Provides NamedaysInADayPresenter presenter(NamedayCalendar calendar, NamedaysViewModelFactory namedaysViewModelFactory, ContactsProvider contactsProvider, NamedayUserSettings namedayUserSettings) { return new NamedaysInADayPresenter(calendar, namedaysViewModelFactory, contactsProvider, namedayUserSettings, Schedulers.io(), AndroidSchedulers.mainThread()); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NamedaysOnADayActivity.kt ================================================ package com.alexstyl.specialdates.events.namedays.activity import android.content.Context import android.content.Intent import android.os.Bundle import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.RecyclerView import android.view.LayoutInflater import android.widget.TextView import com.alexstyl.specialdates.MementoApplication import com.alexstyl.specialdates.R import com.alexstyl.specialdates.analytics.Analytics import com.alexstyl.specialdates.analytics.Screen import com.alexstyl.specialdates.date.Date import com.alexstyl.specialdates.date.DateLabelCreator import com.alexstyl.specialdates.date.getDateExtraOrThrow import com.alexstyl.specialdates.date.putExtraDate import com.alexstyl.specialdates.images.ImageLoader import com.alexstyl.specialdates.ui.base.ThemedMementoActivity import com.alexstyl.specialdates.ui.widget.MementoToolbar import javax.inject.Inject class NamedaysOnADayActivity : ThemedMementoActivity() { @Inject lateinit var imageLoader: ImageLoader @Inject lateinit var presenter: NamedaysInADayPresenter @Inject lateinit var dateLabelCreator: DateLabelCreator @Inject lateinit var analytics: Analytics private var dateView: TextView? = null private var namedaysOnADayNavigator: NamedaysOnADayNavigator? = null private lateinit var view: NamedaysOnADayView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_namedays) val applicationModule = (application as MementoApplication).applicationModule applicationModule.inject(this) analytics.trackScreen(Screen.NAMEDAYS) namedaysOnADayNavigator = NamedaysOnADayNavigator(this, analytics) val toolbar = findViewById(R.id.memento_toolbar) toolbar.displayNavigationIconAsUp() setSupportActionBar(toolbar) dateView = findViewById(R.id.namedays_date) val recyclerView = findViewById(R.id.namedays_list) recyclerView.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false) val date = intent.getDateExtraOrThrow() dateView?.text = dateLabelCreator.createWithYearPreferred(date) val layoutInflater = LayoutInflater.from(this) val adapter = NamedaysScreenAdapter( NamedaysScreenViewHolderFactory(layoutInflater, imageLoader), { contact -> namedaysOnADayNavigator?.toContactDetails(contact) } ) recyclerView.adapter = adapter view = AndroidNamedaysOnADayView(adapter) } override fun onStart() { super.onStart() val date = intent.getDateExtraOrThrow() presenter.startPresenting(view, date) } override fun onStop() { super.onStop() presenter.stopPresenting() } companion object { fun getStartIntent(context: Context, date: Date): Intent { return Intent(context, NamedaysOnADayActivity::class.java) .putExtraDate(date) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NamedaysOnADayNavigator.kt ================================================ package com.alexstyl.specialdates.events.namedays.activity import android.app.Activity import com.alexstyl.specialdates.analytics.Analytics import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.person.PersonActivity class NamedaysOnADayNavigator(private val activity: Activity, private val analytics: Analytics) { fun toContactDetails(contact: Contact) { val intent = PersonActivity.buildIntentFor(activity, contact) activity.startActivity(intent) analytics.trackNamedaysScreen() } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NamedaysScreenAdapter.java ================================================ package com.alexstyl.specialdates.events.namedays.activity; import android.support.annotation.NonNull; import android.support.v7.util.DiffUtil; import android.support.v7.widget.RecyclerView; import android.view.ViewGroup; import com.alexstyl.specialdates.contact.Contact; import java.util.ArrayList; import java.util.List; import kotlin.Unit; import kotlin.jvm.functions.Function1; public class NamedaysScreenAdapter extends RecyclerView.Adapter { private final List viewModels = new ArrayList<>(); private final NamedaysScreenViewHolderFactory viewholderFactory; private final Function1 onContactClicked; public NamedaysScreenAdapter(NamedaysScreenViewHolderFactory viewholderFactory, Function1 onContactClicked) { this.viewholderFactory = viewholderFactory; this.onContactClicked = onContactClicked; } @Override public int getItemViewType(int position) { return viewModels.get(position).getViewType(); } @NonNull @Override public NamedayScreenViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { return viewholderFactory.viewHolderFor(parent, viewType); } @Override @SuppressWarnings("unchecked") public void onBindViewHolder(@NonNull NamedayScreenViewHolder holder, int position) { holder.bind(viewModels.get(position), onContactClicked); } void display(List viewModels) { DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new NamedaysViewModelDiff(this.viewModels, viewModels)); this.viewModels.clear(); this.viewModels.addAll(viewModels); diffResult.dispatchUpdatesTo(this); } @Override public int getItemCount() { return viewModels.size(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NamedaysScreenViewHolderFactory.kt ================================================ package com.alexstyl.specialdates.events.namedays.activity import android.view.LayoutInflater import android.view.ViewGroup import android.widget.TextView import com.alexstyl.specialdates.R import com.alexstyl.specialdates.images.ImageLoader import com.alexstyl.specialdates.ui.widget.ColorImageView class NamedaysScreenViewHolderFactory(private val layoutInflater: LayoutInflater, private val imageLoader: ImageLoader) { fun viewHolderFor(parent: ViewGroup, @NamedayScreenViewType viewType: Int): NamedayScreenViewHolder<*> { return when (viewType) { NamedayScreenViewType.NAMEDAY -> { val view = layoutInflater.inflate(R.layout.row_nameday_name, parent, false) val nameView = view.findViewById(R.id.nameday_name) NameViewHolder(view, nameView) } NamedayScreenViewType.CONTACT -> { val view = layoutInflater.inflate(R.layout.row_nameday_contact, parent, false) val nameView = view.findViewById(R.id.row_nameday_contact_name) val avatarView = view.findViewById(R.id.row_nameday_contact_avatar) CelebratingContactViewHolder(view, imageLoader, avatarView, nameView) } else -> throw UnsupportedOperationException("Unsupported view type $viewType") } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/activity/NamedaysViewModelDiff.java ================================================ package com.alexstyl.specialdates.events.namedays.activity; import android.support.v7.util.DiffUtil; import java.util.List; final class NamedaysViewModelDiff extends DiffUtil.Callback { private final List oldModels; private final List newModels; NamedaysViewModelDiff(List oldModels, List newModels) { this.oldModels = oldModels; this.newModels = newModels; } @Override public int getOldListSize() { return oldModels.size(); } @Override public int getNewListSize() { return newModels.size(); } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { return oldModels.get(oldItemPosition).getViewType() == newModels.get(newItemPosition).getViewType() && oldModels.get(oldItemPosition).getId() == newModels.get(newItemPosition).getId(); } @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { return oldModels.get(oldItemPosition).equals(newModels.get(newItemPosition)); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/namedays/calendar/resource/AndroidJSONResourceLoader.kt ================================================ package com.alexstyl.specialdates.events.namedays.calendar.resource import android.content.res.Resources import com.alexstyl.specialdates.R import com.alexstyl.specialdates.events.namedays.NamedayLocale import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream import org.json.JSONException import org.json.JSONObject class AndroidJSONResourceLoader(private val resources: Resources) : NamedayJSONResourceLoader { @Throws(JSONException::class) override fun loadJSON(locale: NamedayLocale): JSONObject { val inputStream = resources.openRawResource(locale.rawResId()) val outputStream = ByteArrayOutputStream() var ctr: Int try { ctr = inputStream.read() while (ctr != -1) { outputStream.write(ctr) ctr = inputStream.read() } inputStream.close() return JSONObject(outputStream.toString("UTF-8")) } catch (e: IOException) { throw JSONException(e.message) } catch (e: JSONException) { throw JSONException(e.message) } } } private fun NamedayLocale.rawResId(): Int = when(this){ NamedayLocale.GREEK -> R.raw.gr_namedays NamedayLocale.ROMANIAN -> R.raw.ro_namedays NamedayLocale.RUSSIAN -> R.raw.ru_namedays NamedayLocale.LATVIAN -> R.raw.lv_namedays NamedayLocale.LATVIAN_EXTENDED -> R.raw.lv_ext_namedays NamedayLocale.SLOVAK -> R.raw.sk_namedays NamedayLocale.ITALIAN -> R.raw.it_namedays NamedayLocale.CZECH -> R.raw.cs_namedays NamedayLocale.HUNGARIAN -> R.raw.hu_namedays } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/peopleevents/AndroidPeopleEventsPersister.kt ================================================ package com.alexstyl.specialdates.events.peopleevents import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteException import android.database.sqlite.SQLiteOpenHelper import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.contact.ContactSource import com.alexstyl.specialdates.date.ContactEvent import com.alexstyl.specialdates.events.database.DatabaseContract.AnnualEventsContract import com.alexstyl.specialdates.events.database.EventColumns import com.alexstyl.specialdates.events.database.EventTypeId.TYPE_NAMEDAY class AndroidPeopleEventsPersister(private val helper: SQLiteOpenHelper, private val marshaller: ContactEventsMarshaller, private val tracker: CrashAndErrorTracker) : PeopleEventsPersister { override fun deleteAllNamedays() { helper.writableDatabase .executeTransaction { delete( AnnualEventsContract.TABLE_NAME, "${AnnualEventsContract.EVENT_TYPE} == $TYPE_NAMEDAY", null) } } override fun deleteAllEventsOfSource(@ContactSource source: Int) { helper.writableDatabase .executeTransaction { delete(AnnualEventsContract.TABLE_NAME, AnnualEventsContract.SOURCE + "==" + source, null) } } override fun deleteAllDeviceEvents() { helper.writableDatabase .executeTransaction { delete(AnnualEventsContract.TABLE_NAME, "${EventColumns.SOURCE} == ${ContactSource.SOURCE_DEVICE}" + " AND ${EventColumns.EVENT_TYPE} != ${StandardEventType.NAMEDAY.id}" , null) } } override fun insertAnnualEvents(events: List) { helper.writableDatabase .executeTransaction { marshaller .marshall(events) .forEach { contentValues -> insert(AnnualEventsContract.TABLE_NAME, null, contentValues) } } } override fun markContactAsVisible(contact: Contact) { helper.writableDatabase .executeTransaction { update( AnnualEventsContract.TABLE_NAME, visible(), AnnualEventsContract.CONTACT_ID + " = " + contact.contactID + " AND " + AnnualEventsContract.SOURCE + " = " + contact.source, null) } } private fun visible(): ContentValues { return ContentValues(1).apply { put(AnnualEventsContract.VISIBLE, 1) } } override fun markContactAsHidden(contact: Contact) { helper.writableDatabase .executeTransaction { update(AnnualEventsContract.TABLE_NAME, hidden(), AnnualEventsContract.CONTACT_ID + " = " + contact.contactID + " AND " + AnnualEventsContract.SOURCE + " = " + contact.source, null) } } private fun hidden(): ContentValues { val values = ContentValues(1) values.put(AnnualEventsContract.VISIBLE, 0) return values } override fun getVisibilityFor(contact: Contact): Boolean { val database = helper.writableDatabase // TODO just COUNT() events the contact has val query = database.query( AnnualEventsContract.TABLE_NAME, null, AnnualEventsContract.CONTACT_ID + " == " + contact.contactID + " AND " + AnnualEventsContract.SOURCE + " == " + contact.source + " AND " + AnnualEventsContract.VISIBLE + " = 1", null, null, null, null ) val count = query.count query.close() return count > 0 } private inline fun SQLiteDatabase.executeTransaction(function: SQLiteDatabase.() -> Unit) { try { this.beginTransaction() function(this) this.setTransactionSuccessful() } catch (e: SQLiteException) { tracker.track(e) } finally { this.endTransaction() } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/peopleevents/AndroidPeopleEventsProvider.kt ================================================ package com.alexstyl.specialdates.events.peopleevents import android.database.Cursor import android.database.MergeCursor import android.database.sqlite.SQLiteDatabase import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.Optional import com.alexstyl.specialdates.SQLArgumentBuilder import com.alexstyl.specialdates.contact.Contact import com.alexstyl.specialdates.contact.ContactNotFoundException import com.alexstyl.specialdates.contact.ContactSource import com.alexstyl.specialdates.contact.Contacts import com.alexstyl.specialdates.contact.ContactsProvider import com.alexstyl.specialdates.date.ContactEvent import com.alexstyl.specialdates.date.Date import com.alexstyl.specialdates.date.DateParser import com.alexstyl.specialdates.date.TimePeriod import com.alexstyl.specialdates.events.database.DatabaseContract.AnnualEventsContract import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper import com.alexstyl.specialdates.events.database.EventTypeId import com.alexstyl.specialdates.events.database.EventTypeId.TYPE_CUSTOM import com.novoda.notils.logger.simple.Log class AndroidPeopleEventsProvider(private val eventSQLHelper: EventSQLiteOpenHelper, private val contactsProvider: ContactsProvider, private val customEventProvider: CustomEventProvider, private val dateParser: DateParser, private val tracker: CrashAndErrorTracker, private val shortDateLabelCreator: ShortDateLabelCreator) : PeopleEventsProvider { override fun fetchEventsOn(date: Date): ContactEventsOnADate { return ContactEventsOnADate.createFrom(date, fetchEventsBetween(TimePeriod.between(date, date))) } override fun fetchEventsBetween(timePeriod: TimePeriod): List { val cursor = queryEventsFor(timePeriod) val contactEvents = ArrayList(cursor.count) val deviceIds = ArrayList(cursor.count) val facebookIds = ArrayList(cursor.count) while (cursor.moveToNext()) { val contactId = getContactIdFrom(cursor) val source = getContactSourceFrom(cursor) when (source) { ContactSource.SOURCE_DEVICE -> deviceIds.add(contactId) ContactSource.SOURCE_FACEBOOK -> facebookIds.add(contactId) else -> throw UnsupportedOperationException("Source $source not managed") } } val deviceContacts = contactsProvider.getContacts(deviceIds, ContactSource.SOURCE_DEVICE) val facebookContacts = contactsProvider.getContacts(facebookIds, ContactSource.SOURCE_FACEBOOK) val contacts = HashMap() contacts[ContactSource.SOURCE_DEVICE] = deviceContacts contacts[ContactSource.SOURCE_FACEBOOK] = facebookContacts cursor.moveToFirst() while (!cursor.isAfterLast) { try { val contactsOfSource = contacts[getContactSourceFrom(cursor)] val contact = contactsOfSource?.getContact(getContactIdFrom(cursor)) if (contact != null) { val contactEvent = getContactEventFrom(cursor, contact) contactEvents.add(contactEvent) } } catch (e: ContactNotFoundException) { tracker.track(e) } cursor.moveToNext() } cursor.close() return contactEvents.toList() } override fun fetchEventsFor(contact: Contact): List { val contactEvents = ArrayList() val cursor = queryEventsOf(contact) while (cursor.moveToNext()) { try { val contactEvent = getContactEventFrom(cursor, contact) contactEvents.add(contactEvent) } catch (e: ContactNotFoundException) { Log.w(e) } } cursor.close() return contactEvents.toList() } private fun queryEventsFor(timeDuration: TimePeriod): Cursor { return if (isWithinTheSameYear(timeDuration)) { queryPeopleEvents(timeDuration, AnnualEventsContract.DATE + " ASC") } else { queryAllYearsIn(timeDuration) } } private fun queryEventsOf(contact: Contact): Cursor { val selectArgs = arrayOf(contact.contactID.toString(), contact.source.toString()) // query database return eventSQLHelper.readableDatabase.query( AnnualEventsContract.TABLE_NAME, PROJECTION, AnnualEventsContract.CONTACT_ID + " = ? " + "AND " + AnnualEventsContract.SOURCE + " = ?", selectArgs, null, null, null ) } private fun queryPeopleEvents(timePeriod: TimePeriod, sortOrder: String): Cursor { val selectArgs = arrayOf(SQLArgumentBuilder.dateWithoutYear(timePeriod.startingDate), SQLArgumentBuilder.dateWithoutYear(timePeriod.endingDate) ) return eventSQLHelper.readableDatabase.query( AnnualEventsContract.TABLE_NAME, PROJECTION, DATE_BETWEEN_IGNORING_YEAR, selectArgs, null, null, sortOrder ) } private fun queryAllYearsIn(timeDuration: TimePeriod): Cursor { val firstHalf = firstHalfOf(timeDuration) val cursors = arrayOfNulls(2) cursors[0] = queryPeopleEvents(firstHalf, AnnualEventsContract.DATE + " ASC") val secondHalf = secondHalfOf(timeDuration) cursors[1] = queryPeopleEvents(secondHalf, AnnualEventsContract.DATE + " ASC") return MergeCursor(cursors) } private fun isWithinTheSameYear(timeDuration: TimePeriod): Boolean { return timeDuration.startingDate.year == timeDuration.endingDate.year } override fun findClosestEventDateOnOrAfter(date: Date): Date? = eventSQLHelper .readableDatabase .queryFirstEventOnOrAfter(date) .use { cursor -> return if (cursor.moveToFirst()) { return cursor.getDate().withYear(date.year) } else { null } } private fun Date.withYear(year: Int): Date = Date.on(this.dayOfMonth, this.month, year) private fun SQLiteDatabase.queryFirstEventOnOrAfter(date: Date): Cursor = query( AnnualEventsContract.TABLE_NAME, AndroidPeopleEventsProvider.PEOPLE_PROJECTION, "${AndroidPeopleEventsProvider.DATE_COLUMN_WITHOUT_YEAR} >= ?", monthAndDayOf(date), null, null, "${AndroidPeopleEventsProvider.DATE_COLUMN_WITHOUT_YEAR} ASC LIMIT 1") private fun monthAndDayOf(date: Date): Array { return arrayOf(shortDateLabelCreator.createLabelWithNoYearFor(date)) } private fun getEventType(cursor: Cursor): EventType { val eventTypeIndex = cursor.getColumnIndexOrThrow(AnnualEventsContract.EVENT_TYPE) @EventTypeId val rawEventType = cursor.getInt(eventTypeIndex) if (rawEventType == TYPE_CUSTOM) { val deviceEventIdFrom = getDeviceEventIdFrom(cursor) return if (deviceEventIdFrom.isPresent) { queryCustomEvent(deviceEventIdFrom.get()) } else StandardEventType.OTHER } return StandardEventType.fromId(rawEventType) } @Throws(ContactNotFoundException::class) private fun getContactEventFrom(cursor: Cursor, contact: Contact): ContactEvent { val date = cursor.getDate() val eventType = getEventType(cursor) val eventId = getDeviceEventIdFrom(cursor) return ContactEvent(eventId, eventType, date, contact) } @ContactSource private fun getContactSourceFrom(cursor: Cursor): Int { val sourceTypeIndex = cursor.getColumnIndexOrThrow(AnnualEventsContract.SOURCE) return cursor.getInt(sourceTypeIndex) } private fun queryCustomEvent(deviceId: Long): EventType { return customEventProvider.getEventWithId(deviceId) } private fun Cursor.getDate(): Date { val index = getColumnIndexOrThrow(AnnualEventsContract.DATE) val rawDate = getString(index) return dateParser.parse(rawDate) } companion object { private const val DATE_FROM = "substr(" + AnnualEventsContract.DATE + ",-5) >= ?" private const val DATE_TO = "substr(" + AnnualEventsContract.DATE + ",-5) <= ?" private const val DATE_BETWEEN_IGNORING_YEAR = DATE_FROM + " AND " + DATE_TO + " AND " + AnnualEventsContract.VISIBLE + " == 1" private val PEOPLE_PROJECTION = arrayOf(AnnualEventsContract.DATE) private val PROJECTION = arrayOf( AnnualEventsContract.CONTACT_ID, AnnualEventsContract.DEVICE_EVENT_ID, AnnualEventsContract.DATE, AnnualEventsContract.EVENT_TYPE, AnnualEventsContract.SOURCE ) /* We use this column in order to be able to do comparisons of dates, without having to worry about the year So, instead of a full date 1990-12-19, this will return 12-19. Similarwise for --12-19. an example in use: select * from annual_events WHERE substr(date,-5) >= '03-04' ORDER BY substr(date,-5) asc LIMIT 1 */ private const val DATE_COLUMN_WITHOUT_YEAR = "substr(" + AnnualEventsContract.DATE + ", -5) " private fun firstHalfOf(timeDuration: TimePeriod): TimePeriod { return TimePeriod.between( timeDuration.startingDate, Date.endOfYear(timeDuration.startingDate.year) ) } private fun secondHalfOf(timeDuration: TimePeriod): TimePeriod { return TimePeriod.between( Date.startOfYear(timeDuration.endingDate.year), timeDuration.endingDate ) } private fun getContactIdFrom(cursor: Cursor): Long { val contactIdIndex = cursor.getColumnIndexOrThrow(AnnualEventsContract.CONTACT_ID) return cursor.getLong(contactIdIndex) } private fun getDeviceEventIdFrom(cursor: Cursor): Optional { val eventId = cursor.getColumnIndexOrThrow(AnnualEventsContract.DEVICE_EVENT_ID) val deviceEventId = cursor.getLong(eventId) return if (isALegitEventId(deviceEventId)) { Optional.absent() } else Optional(deviceEventId) } private fun isALegitEventId(deviceEventId: Long): Boolean { return deviceEventId == -1L } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/peopleevents/AndroidPeopleEventsRepository.kt ================================================ package com.alexstyl.specialdates.events.peopleevents import android.content.ContentResolver import android.database.Cursor import android.provider.ContactsContract import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.Optional import com.alexstyl.specialdates.contact.ContactNotFoundException import com.alexstyl.specialdates.contact.ContactSource.SOURCE_DEVICE import com.alexstyl.specialdates.contact.ContactsProvider import com.alexstyl.specialdates.date.ContactEvent import com.alexstyl.specialdates.date.Date import com.alexstyl.specialdates.date.DateParseException import com.alexstyl.specialdates.date.DateParser import com.novoda.notils.logger.simple.Log class AndroidPeopleEventsRepository(private val contentResolver: ContentResolver, private val contactsProvider: ContactsProvider, private val dateParser: DateParser, private val tracker: CrashAndErrorTracker) : PeopleEventsRepository { override fun fetchPeopleWithEvents(): List { val cursor = contentResolver.query(CONTENT_URI, PROJECTION, SELECTION, SELECT_ARGS, SORT_ORDER) if (isInvalid(cursor)) { return emptyList() } val events = ArrayList() try { while (cursor!!.moveToNext()) { val contactId = getContactIdFrom(cursor) val eventType = getEventTypeFrom(cursor) try { val eventDate = getEventDateFrom(cursor) val eventId = getEventIdFrom(cursor) val contact = contactsProvider.getContact(contactId, SOURCE_DEVICE) events.add(ContactEvent(Optional(eventId), eventType, eventDate, contact)) } catch (e: DateParseException) { tracker.track(e) } catch (e: ContactNotFoundException) { Log.e(e) } } } finally { cursor!!.close() } return events } private fun getContactIdFrom(cursor: Cursor): Long { val contactIdIndex = cursor.getColumnIndex(ContactsContract.Data.CONTACT_ID) return cursor.getLong(contactIdIndex) } @Throws(DateParseException::class) private fun getEventDateFrom(cursor: Cursor): Date { val dateIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Event.START_DATE) val dateRaw = cursor.getString(dateIndex) return dateParser.parse(dateRaw) } private fun getEventTypeFrom(cursor: Cursor): EventType { val eventTypeIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Event.TYPE) val eventTypeRaw = cursor.getInt(eventTypeIndex) return when (eventTypeRaw) { ContactsContract.CommonDataKinds.Event.TYPE_BIRTHDAY -> StandardEventType.BIRTHDAY ContactsContract.CommonDataKinds.Event.TYPE_ANNIVERSARY -> StandardEventType.ANNIVERSARY ContactsContract.CommonDataKinds.Event.TYPE_CUSTOM -> StandardEventType.CUSTOM else -> StandardEventType.OTHER } } private fun isInvalid(cursor: Cursor?): Boolean { return cursor == null || cursor.isClosed } companion object { private val CONTENT_URI = ContactsContract.Data.CONTENT_URI private val PROJECTION = arrayOf( ContactsContract.Data.CONTACT_ID, ContactsContract.CommonDataKinds.Event.TYPE, ContactsContract.CommonDataKinds.Event._ID, ContactsContract.CommonDataKinds.Event.START_DATE ) private const val SELECTION = ( "( " + ContactsContract.Data.MIMETYPE + " = ? " + " AND " + ContactsContract.Data.IN_VISIBLE_GROUP + " = 1)") private val SELECT_ARGS = arrayOf(ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE) private val SORT_ORDER: String? = null private fun getEventIdFrom(cursor: Cursor): Long { val eventIdIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Event._ID) return cursor.getLong(eventIdIndex) } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/peopleevents/AndroidUpcomingEventSettings.kt ================================================ package com.alexstyl.specialdates.events.peopleevents import android.content.Context import com.alexstyl.specialdates.EasyPreferences import com.alexstyl.specialdates.R import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper class AndroidUpcomingEventSettings(context: Context) : UpcomingEventsSettings { private val preferences: EasyPreferences = EasyPreferences.createForPrivatePreferences(context, R.string.pref_events) override fun hasBeenInitialised(): Boolean { return isUpdatedVersion() && preferences.getBoolean(R.string.key_events_are_initialised, false) } private fun isUpdatedVersion(): Boolean { val lastDatabaseVersion = preferences.getInt(R.string.key_database_version, -1) return lastDatabaseVersion >= EventSQLiteOpenHelper.DATABASE_VERSION } override fun markEventsAsInitialised() { preferences.setBoolean(R.string.key_events_are_initialised, true) preferences.setInteger(R.string.key_database_version, EventSQLiteOpenHelper.DATABASE_VERSION) } override fun reset() { preferences.setBoolean(R.string.key_events_are_initialised, false) } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/peopleevents/ContactEventsMarshaller.java ================================================ package com.alexstyl.specialdates.events.peopleevents; import android.content.ContentValues; import com.alexstyl.specialdates.Optional; import com.alexstyl.specialdates.contact.Contact; import com.alexstyl.specialdates.date.ContactEvent; import com.alexstyl.specialdates.events.database.DatabaseContract.AnnualEventsContract; import java.util.List; public class ContactEventsMarshaller { private static final int DEFAULT_VALUES_SIZE = 5; private static final int IS_VISIBILE = 1; private final ShortDateLabelCreator dateLabelCreator; public ContactEventsMarshaller(ShortDateLabelCreator dateLabelCreator) { this.dateLabelCreator = dateLabelCreator; } public ContentValues[] marshall(List item) { ContentValues[] returningValues = new ContentValues[item.size()]; for (int i = 0; i < item.size(); i++) { ContactEvent event = item.get(i); returningValues[i] = createValuesFor(event); } return returningValues; } private ContentValues createValuesFor(ContactEvent event) { Contact contact = event.getContact(); ContentValues values = new ContentValues(DEFAULT_VALUES_SIZE); values.put(AnnualEventsContract.CONTACT_ID, contact.getContactID()); values.put(AnnualEventsContract.DISPLAY_NAME, contact.getDisplayName().toString()); values.put(AnnualEventsContract.DATE, dateLabelCreator.createLabelWithYearPreferredFor(event.getDate())); values.put(AnnualEventsContract.EVENT_TYPE, event.getType().getId()); values.put(AnnualEventsContract.SOURCE, contact.getSource()); values.put(AnnualEventsContract.VISIBLE, IS_VISIBILE); putDeviceContactIdIfPresent(event, values); return values; } private void putDeviceContactIdIfPresent(ContactEvent event, ContentValues values) { Optional deviceEventId = event.getDeviceEventId(); if (deviceEventId.isPresent()) { values.put(AnnualEventsContract.DEVICE_EVENT_ID, deviceEventId.get()); } else { values.put(AnnualEventsContract.DEVICE_EVENT_ID, -1); } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/peopleevents/CustomEventProvider.kt ================================================ package com.alexstyl.specialdates.events.peopleevents import android.content.ContentResolver import android.provider.ContactsContract class CustomEventProvider(private val resolver: ContentResolver) { fun getEventWithId(deviceId: Long): EventType { resolver .query(CONTENT_URI, PROJECTION, SELECTION, arrayOf(deviceId.toString(), ContactsContract.CommonDataKinds.Event.CONTENT_ITEM_TYPE), SORT_ORDER) .use { cursor -> if (cursor.moveToFirst()) { val columnIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Event.LABEL) val eventName = cursor.getString(columnIndex) return CustomEventType(eventName) } } return StandardEventType.OTHER } companion object { private val CONTENT_URI = ContactsContract.Data.CONTENT_URI private val PROJECTION = arrayOf(ContactsContract.CommonDataKinds.Event.LABEL) private const val SELECTION = ( "( " + ContactsContract.Data._ID + " = ?" + " AND " + ContactsContract.Data.MIMETYPE + " = ? " + " AND " + ContactsContract.Data.IN_VISIBLE_GROUP + " = 1" + ")") private const val SORT_ORDER = ContactsContract.CommonDataKinds.Event._ID + " LIMIT 1" } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/events/peopleevents/PeopleEventsModule.kt ================================================ package com.alexstyl.specialdates.events.peopleevents import android.appwidget.AppWidgetManager import android.content.ContentResolver import android.content.Context import com.alexstyl.specialdates.CrashAndErrorTracker import com.alexstyl.specialdates.contact.ContactsProvider import com.alexstyl.specialdates.date.DateParser import com.alexstyl.specialdates.events.SettingsPresenter import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper import com.alexstyl.specialdates.events.namedays.NamedayDatabaseRefresher import com.alexstyl.specialdates.events.namedays.NamedayUserSettings import com.alexstyl.specialdates.events.namedays.calendar.resource.NamedayCalendarProvider import com.alexstyl.specialdates.upcoming.widget.list.UpcomingEventsScrollingWidgetView import com.alexstyl.specialdates.upcoming.widget.today.TodayUpcomingEventsView import com.alexstyl.specialdates.wear.WearSyncUpcomingEventsView import dagger.Module import dagger.Provides import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import javax.inject.Singleton @Module @Singleton class PeopleEventsModule(private val context: Context) { @Provides fun peopleEventsProvider(peopleDynamicNamedaysProvider: PeopleDynamicNamedaysProvider, androidPeopleEventsProvider: AndroidPeopleEventsProvider): PeopleEventsProvider { return CompositePeopleEventsProvider(listOf(peopleDynamicNamedaysProvider, androidPeopleEventsProvider)) } @Provides fun androidPeopleEventsProvider(sqLiteOpenHelper: EventSQLiteOpenHelper, contactsProvider: ContactsProvider, dateParser: DateParser, tracker: CrashAndErrorTracker, shortLabelCreator: ShortDateLabelCreator): AndroidPeopleEventsProvider { return AndroidPeopleEventsProvider( sqLiteOpenHelper, contactsProvider, CustomEventProvider(context.contentResolver), dateParser, tracker, shortLabelCreator ) } @Provides fun peopleNamedayCalculator(namedayPreferences: NamedayUserSettings, namedaysCalendarProvider: NamedayCalendarProvider, contactsProvider: ContactsProvider): PeopleDynamicNamedaysProvider { return PeopleDynamicNamedaysProvider(namedayPreferences, namedaysCalendarProvider, contactsProvider) } @Provides @Singleton fun peopleEventsViewRefresher(appContext: Context, appWidgetManager: AppWidgetManager): UpcomingEventsViewRefresher { return UpcomingEventsViewRefresher(mutableSetOf( WearSyncUpcomingEventsView(appContext), TodayUpcomingEventsView(appContext, appWidgetManager), UpcomingEventsScrollingWidgetView(appContext, appWidgetManager) )) } @Provides fun peopleEventsStaticEventsRefresher( eventSQlite: EventSQLiteOpenHelper, contentResolver: ContentResolver, contactsProvider: ContactsProvider, dateParser: DateParser, marshaller: ContactEventsMarshaller, tracker: CrashAndErrorTracker): PeopleEventsStaticEventsRefresher { val repository = AndroidPeopleEventsRepository(contentResolver, contactsProvider, dateParser, tracker) val androidPeopleEventsPersister = AndroidPeopleEventsPersister(eventSQlite, marshaller, tracker) return PeopleEventsStaticEventsRefresher(repository, androidPeopleEventsPersister) } @Provides fun namedayDatabaseRefresher(namedayUserSettings: NamedayUserSettings, databaseProvider: PeopleEventsPersister, provider: PeopleDynamicNamedaysProvider): NamedayDatabaseRefresher { return NamedayDatabaseRefresher(namedayUserSettings, databaseProvider, provider) } @Provides fun peopleEventsUpdater(staticRefresher: PeopleEventsStaticEventsRefresher, namedayRefresher: NamedayDatabaseRefresher, viewRefresher: UpcomingEventsViewRefresher, settings: UpcomingEventsSettings): PeopleEventsUpdater { return PeopleEventsUpdater( staticRefresher, namedayRefresher, viewRefresher, settings, Schedulers.io(), AndroidSchedulers.mainThread() ) } @Provides fun marshaller(dateLabelCreator: ShortDateLabelCreator) = ContactEventsMarshaller(dateLabelCreator) @Provides fun peopleEventsPersister(tracker: CrashAndErrorTracker, marshaller: ContactEventsMarshaller, helper: EventSQLiteOpenHelper): PeopleEventsPersister { return AndroidPeopleEventsPersister(helper, marshaller, tracker) } @Provides fun eventPreferences(): UpcomingEventsSettings { return AndroidUpcomingEventSettings(context) } @Provides fun peopleEventsDatabaseUpdater(uiRefresher: UpcomingEventsViewRefresher, peopleEventsUpdater: PeopleEventsUpdater): SettingsPresenter { return SettingsPresenter(peopleEventsUpdater, uiRefresher, Schedulers.io()) } @Provides fun shortDateCreator() = ShortDateLabelCreator() } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/AndroidFacebookPreferences.java ================================================ package com.alexstyl.specialdates.facebook; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.R; public final class AndroidFacebookPreferences implements FacebookUserSettings { private final EasyPreferences preferences; AndroidFacebookPreferences(EasyPreferences preferences) { this.preferences = preferences; } @Override public void store(UserCredentials userCredentials) { preferences.setLong(R.string.key_facebook_user_id, userCredentials.getUid()); preferences.setString(R.string.key_facebook_user_key, userCredentials.getKey()); preferences.setString(R.string.key_facebook_user_name, userCredentials.getName()); } @Override public UserCredentials retrieveCredentials() { long uid = preferences.getLong(R.string.key_facebook_user_id, -1); String key = preferences.getString(R.string.key_facebook_user_key, ""); String name = preferences.getString(R.string.key_facebook_user_name, ""); return new UserCredentials(uid, key, name); } @Override public boolean isLoggedIn() { return !UserCredentials.ANNONYMOUS.equals(retrieveCredentials()); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/FacebookLogoutService.java ================================================ package com.alexstyl.specialdates.facebook; import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsViewRefresher; import com.alexstyl.specialdates.facebook.friendimport.FacebookFriendsPersister; import io.reactivex.Completable; import io.reactivex.Scheduler; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.functions.Action; import io.reactivex.schedulers.Schedulers; class FacebookLogoutService { private final UpcomingEventsViewRefresher refresher; private final FacebookUserSettings preferences; private final FacebookFriendsPersister persister; private final Scheduler resultScheduler; private final OnFacebookLogOutCallback callback; private CompositeDisposable disposable = new CompositeDisposable(); FacebookLogoutService(Scheduler resultScheduler, FacebookUserSettings preferences, FacebookFriendsPersister persister, UpcomingEventsViewRefresher refresher, OnFacebookLogOutCallback callback) { this.resultScheduler = resultScheduler; this.preferences = preferences; this.persister = persister; this.refresher = refresher; this.callback = callback; } void logOut() { disposable.add( Completable.fromAction(clearAllUserPresence()) .doOnComplete(onLogOut()) .observeOn(resultScheduler) .doOnComplete(refreshAllUI()) .subscribeOn(Schedulers.io()) .subscribe() ); } private Action refreshAllUI() { return new Action() { @Override public void run() throws Exception { refresher.refreshViews(); } }; } private Action clearAllUserPresence() { return new Action() { @Override public void run() throws Exception { preferences.store(UserCredentials.ANNONYMOUS); persister.removeAllFriends(); } }; } private Action onLogOut() { return new Action() { @Override public void run() throws Exception { callback.onUserLogOut(); } }; } void dispose() { disposable.dispose(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/FacebookModule.java ================================================ package com.alexstyl.specialdates.facebook; import android.content.Context; import com.alexstyl.specialdates.EasyPreferences; import com.alexstyl.specialdates.R; import dagger.Module; import dagger.Provides; @Module public class FacebookModule { private final Context context; public FacebookModule(Context context) { this.context = context; } @Provides FacebookUserSettings userSettings() { return new AndroidFacebookPreferences(EasyPreferences.createForPrivatePreferences(context, R.string.pref_facebook)); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/FacebookProfileActivity.java ================================================ package com.alexstyl.specialdates.facebook; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import com.alexstyl.specialdates.AppComponent; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.ExternalNavigator; import com.alexstyl.specialdates.MementoApplication; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.analytics.Analytics; import com.alexstyl.specialdates.analytics.Screen; import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper; import com.alexstyl.specialdates.events.peopleevents.ContactEventsMarshaller; import com.alexstyl.specialdates.events.peopleevents.AndroidPeopleEventsPersister; import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsViewRefresher; import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsSettings; import com.alexstyl.specialdates.facebook.friendimport.FacebookFriendsPersister; import com.alexstyl.specialdates.images.ImageLoader; import com.alexstyl.specialdates.ui.base.ThemedMementoActivity; import com.alexstyl.specialdates.ui.widget.MementoToolbar; import javax.inject.Inject; import java.net.URI; import io.reactivex.android.schedulers.AndroidSchedulers; import static com.novoda.notils.caster.Views.findById; public class FacebookProfileActivity extends ThemedMementoActivity implements FacebookProfileView { private static final int LOGOUT_ID = 40444; private ExternalNavigator navigator; private FacebookProfilePresenter presenter; private ImageView profilePicture; private TextView userName; @Inject Analytics analytics; @Inject ImageLoader imageLoader; @Inject UpcomingEventsViewRefresher uiRefresher; @Inject CrashAndErrorTracker tracker; @Inject FacebookUserSettings facebookSettings; @Inject UpcomingEventsSettings eventsSettings; @Inject ContactEventsMarshaller marshaller; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppComponent applicationModule = ((MementoApplication) getApplication()).getApplicationModule(); applicationModule.inject(this); analytics.trackScreen(Screen.FACEBOOK_PROFILE); setContentView(R.layout.activity_facebook_profile); setupToolbar(); profilePicture = findById(this, R.id.facebook_profile_avatar); userName = findById(this, R.id.facebook_profile_name); findById(this, R.id.facebook_profile_fb_page).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { navigator.toFacebookPage(); } }); FacebookFriendsPersister persister = new FacebookFriendsPersister( new AndroidPeopleEventsPersister(new EventSQLiteOpenHelper(this), marshaller, tracker)); navigator = new ExternalNavigator(this, analytics, tracker); FacebookLogoutService service = new FacebookLogoutService( AndroidSchedulers.mainThread(), facebookSettings, persister, uiRefresher, onLogOut() ); presenter = new FacebookProfilePresenter( service, this, facebookSettings ); presenter.startPresenting(); } private OnFacebookLogOutCallback onLogOut() { return new OnFacebookLogOutCallback() { @Override public void onUserLogOut() { finish(); } }; } private void setupToolbar() { MementoToolbar toolbar = findById(this, R.id.memento_toolbar); setSupportActionBar(toolbar); toolbar.displayNavigationIconAsClose(); setTitle(null); } @Override public void display(UserCredentials userCredentials) { userName.setText(userCredentials.getName()); URI uri = FacebookImagePath.INSTANCE.forUid(userCredentials.getUid()); imageLoader .load(uri) .asCircle() .into(profilePicture); } @Override public boolean onCreateOptionsMenu(Menu menu) { menu.add(0, LOGOUT_ID, 0, R.string.log_out); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { int itemId = item.getItemId(); if (itemId == LOGOUT_ID) { presenter.logOut(); analytics.trackFacebookLoggedOut(); return true; } else if (itemId == android.R.id.home) { finish(); } return super.onOptionsItemSelected(item); } @Override protected void onDestroy() { super.onDestroy(); presenter.stopPresenting(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/FacebookProfilePresenter.java ================================================ package com.alexstyl.specialdates.facebook; class FacebookProfilePresenter { private final FacebookLogoutService service; private final FacebookProfileView view; private final FacebookUserSettings settings; FacebookProfilePresenter(FacebookLogoutService service, FacebookProfileView view, FacebookUserSettings settings) { this.service = service; this.view = view; this.settings = settings; } void startPresenting() { UserCredentials userCredentials = settings.retrieveCredentials(); view.display(userCredentials); } void logOut() { service.logOut(); } void stopPresenting() { service.dispose(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/FacebookProfileView.java ================================================ package com.alexstyl.specialdates.facebook; interface FacebookProfileView { void display(UserCredentials userCredentials); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/OnFacebookLogOutCallback.java ================================================ package com.alexstyl.specialdates.facebook; interface OnFacebookLogOutCallback { void onUserLogOut(); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/ScreenOrientationLock.java ================================================ package com.alexstyl.specialdates.facebook; import android.app.Activity; import android.content.Context; import android.content.pm.ActivityInfo; import android.view.Surface; import android.view.WindowManager; public class ScreenOrientationLock { public void lock(Activity activity) { int orientation = activity.getRequestedOrientation(); int rotation = ((WindowManager) activity.getSystemService(Context.WINDOW_SERVICE)) .getDefaultDisplay().getRotation(); switch (rotation) { case Surface.ROTATION_0: orientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; break; case Surface.ROTATION_90: orientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; break; case Surface.ROTATION_180: orientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; break; default: orientation = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; break; } activity.setRequestedOrientation(orientation); } public void unlock(Activity activity) { activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/friendimport/CalendarURLCreator.java ================================================ package com.alexstyl.specialdates.facebook.friendimport; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.facebook.UserCredentials; import java.net.MalformedURLException; import java.net.URL; class CalendarURLCreator { private final CrashAndErrorTracker tracker; CalendarURLCreator(CrashAndErrorTracker tracker) { this.tracker = tracker; } URL createFrom(UserCredentials user) { try { return new URL("https://www.facebook.com/ical/b.php?locale=en_US&uid=" + user.getUid() + "&key=" + user.getKey()); } catch (MalformedURLException e) { tracker.track(e); throw new IllegalArgumentException(e); } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/friendimport/FacebookFriendsIntentService.java ================================================ package com.alexstyl.specialdates.facebook.friendimport; import android.app.IntentService; import android.app.Notification; import android.app.NotificationManager; import android.content.Intent; import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import com.alexstyl.specialdates.BuildConfig; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.MementoApplication; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.date.ContactEvent; import com.alexstyl.specialdates.date.DateParser; import com.alexstyl.specialdates.events.database.EventSQLiteOpenHelper; import com.alexstyl.specialdates.events.peopleevents.AndroidPeopleEventsPersister; import com.alexstyl.specialdates.events.peopleevents.ContactEventsMarshaller; import com.alexstyl.specialdates.events.peopleevents.UpcomingEventsViewRefresher; import com.alexstyl.specialdates.facebook.FacebookUserSettings; import com.alexstyl.specialdates.facebook.UserCredentials; import javax.inject.Inject; import java.net.URL; import java.util.List; public class FacebookFriendsIntentService extends IntentService { private static final String TAG = FacebookFriendsIntentService.class.getSimpleName(); private static final int NOTIFICATION_ID = 123; @Inject UpcomingEventsViewRefresher uiRefresher; @Inject CrashAndErrorTracker tracker; @Inject FacebookUserSettings facebookUserSettings; @Inject DateParser parser; @Inject ContactEventsMarshaller marshaller; public FacebookFriendsIntentService() { super(TAG); } @Override public void onCreate() { super.onCreate(); ((MementoApplication) getApplication()).getApplicationModule().inject(this); } @Override protected void onHandleIntent(@Nullable Intent intent) { FacebookCalendarLoader calendarLoader = new FacebookCalendarLoader(); FacebookContactFactory factory = new FacebookContactFactory(parser); ContactEventSerialiser serialiser = new ContactEventSerialiser(factory, tracker); FacebookBirthdaysProvider calendarFetcher = new FacebookBirthdaysProvider(calendarLoader, serialiser); UserCredentials userCredentials = facebookUserSettings.retrieveCredentials(); if (isAnnonymous(userCredentials)) { tracker.track(new RuntimeException("Tried to fetch events, but was anonymous")); return; } CalendarURLCreator calendarURLCreator = new CalendarURLCreator(tracker); URL calendarUrl = calendarURLCreator.createFrom(userCredentials); FacebookFriendsPersister persister = new FacebookFriendsPersister( new AndroidPeopleEventsPersister( new EventSQLiteOpenHelper(this), marshaller, tracker ) ); try { List friends = calendarFetcher.fetchCalendarFrom(calendarUrl); persister.keepOnly(friends); uiRefresher.refreshViews(); } catch (CalendarFetcherException e) { tracker.track(e); } if (BuildConfig.DEBUG) { notifyServiceRan(); } } private void notifyServiceRan() { Notification notification = new NotificationCompat.Builder(this) .setContentTitle("Friends fetched") .setSmallIcon(R.mipmap.ic_launcher) .build(); ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).notify(NOTIFICATION_ID, notification); } private boolean isAnnonymous(UserCredentials userCredentials) { return UserCredentials.ANNONYMOUS.equals(userCredentials); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/friendimport/FacebookFriendsPersister.java ================================================ package com.alexstyl.specialdates.facebook.friendimport; import com.alexstyl.specialdates.date.ContactEvent; import com.alexstyl.specialdates.events.peopleevents.PeopleEventsPersister; import java.util.List; import static com.alexstyl.specialdates.contact.ContactSource.SOURCE_FACEBOOK; public final class FacebookFriendsPersister { private final PeopleEventsPersister persister; public FacebookFriendsPersister(PeopleEventsPersister persister) { this.persister = persister; } void keepOnly(List friends) { persister.deleteAllEventsOfSource(SOURCE_FACEBOOK); persister.insertAnnualEvents(friends); } public void removeAllFriends() { persister.deleteAllEventsOfSource(SOURCE_FACEBOOK); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/friendimport/FacebookFriendsScheduler.java ================================================ package com.alexstyl.specialdates.facebook.friendimport; import android.app.AlarmManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import com.alexstyl.specialdates.TimeOfDay; import com.alexstyl.specialdates.date.Date; import com.alexstyl.specialdates.date.DateAndTime; public final class FacebookFriendsScheduler { private static final TimeOfDay EIGHT_O_CLOCK = new TimeOfDay(8, 0); private final AlarmManager alarmManager; private final Context context; public FacebookFriendsScheduler(Context context, AlarmManager alarmManager) { this.context = context; this.alarmManager = alarmManager; } public void scheduleNext() { DateAndTime dateAndTime = new DateAndTime(Date.Companion.today().addDay(1), EIGHT_O_CLOCK); PendingIntent pi = PendingIntent.getService( context, 0, new Intent(context, FacebookFriendsIntentService.class), PendingIntent.FLAG_UPDATE_CURRENT ); alarmManager.setRepeating(AlarmManager.RTC, dateAndTime.toMilis(), AlarmManager.INTERVAL_DAY, pi ); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/CookieResetter.java ================================================ package com.alexstyl.specialdates.facebook.login; import android.webkit.CookieManager; import android.webkit.ValueCallback; import com.alexstyl.android.Version; class CookieResetter { private final CookieManager cookieManager; CookieResetter(CookieManager instance) { this.cookieManager = instance; } void clearAll() { removeAllCookies(); cookieManager.setCookie(".facebook.com", "locale="); cookieManager.setCookie(".facebook.com", "datr="); cookieManager.setCookie(".facebook.com", "s="); cookieManager.setCookie(".facebook.com", "csm="); cookieManager.setCookie(".facebook.com", "fr="); cookieManager.setCookie(".facebook.com", "lu="); cookieManager.setCookie(".facebook.com", "c_user="); cookieManager.setCookie(".facebook.com", "xs="); cookieManager.setCookie(".facebook.com", "wd"); cookieManager.setCookie(".facebook.com", "presence"); cookieManager.setCookie(".facebook.com", "act"); cookieManager.setCookie(".facebook.com", "lu"); cookieManager.setCookie(".facebook.com", "pl"); cookieManager.setCookie(".facebook.com", "fr"); cookieManager.setCookie(".facebook.com", "xs"); cookieManager.setCookie(".facebook.com", "c_user"); cookieManager.setCookie(".facebook.com", "sb"); cookieManager.setCookie(".facebook.com", "dats"); cookieManager.setCookie(".facebook.com", "datr"); cookieManager.setCookie(".facebook.com", "locale"); cookieManager.setCookie(".facebook.com", "x-referer"); } private void removeAllCookies() { if (Version.INSTANCE.hasLollipop()) { cookieManager.removeAllCookies(new ValueCallback() { @Override public void onReceiveValue(Boolean value) { } }); } else { cookieManager.removeAllCookie(); } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/CredentialsExtractor.java ================================================ package com.alexstyl.specialdates.facebook.login; import com.alexstyl.specialdates.facebook.UserCredentials; import java.util.regex.Matcher; import java.util.regex.Pattern; class CredentialsExtractor { private static final Pattern BIRTHDAY_PATTERN = Pattern.compile("(www.facebook.com/ical/b.php\\?uid=\\w+&key=.+?(?=\"))"); private static final String UID = "uid="; private static final int UID_LENGTH = UID.length(); private static final String KEY = "key="; private static final int KEY_LENGTH = KEY.length(); private static final int USER_DETAILS = 1; private static final String OPENNING_SPAN = ""; private static final String CLOSING_SPAN = ""; UserCredentials extractCalendarURL(String pageSource) { Matcher matcher = BIRTHDAY_PATTERN.matcher(pageSource); if (matcher.find()) { String url = matcher .group(1) .replace("&", "&"); String name = obtainName(pageSource); return createFrom(url, name); } else { return UserCredentials.ANNONYMOUS; } } static UserCredentials createFrom(String calendarURL, String name) { int indexOfKey = calendarURL.indexOf(KEY); int indexOfUserID = calendarURL.indexOf(UID); int indexOfEnd = calendarURL.indexOf("&", indexOfUserID); long userID = Long.parseLong(calendarURL.substring(indexOfUserID + UID_LENGTH, indexOfEnd)); String key = calendarURL.substring(indexOfKey + KEY_LENGTH); return new UserCredentials(userID, key, name); } private String obtainName(String pageSource) { try { String userDetails = pageSource.split("data-testid=\"blue_bar_profile_link\">")[USER_DETAILS]; int startOfName = userDetails.indexOf(OPENNING_SPAN) + OPENNING_SPAN.length(); int endOfName = userDetails.indexOf(CLOSING_SPAN); return userDetails.substring(startOfName, endOfName); } catch (Exception e) { return ""; } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/FBImportClient.java ================================================ package com.alexstyl.specialdates.facebook.login; import android.graphics.Bitmap; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; class FBImportClient extends WebViewClient { private static final String HTTPS = "https://"; private static final String HTTP = "http://"; private static final String MOBILE_HOME = "m.facebook.com/home.php"; private static final String DESKTOP_HOME = "www.facebook.com/home.php"; private static final String DESKTOP_USER_AGENT = "Mozilla/5.0 (X11; U; Linux i686; en US; rv:1.9.0.4) Gecko/20100101 Firefox/4.0"; private static final String URL_WITH_BIRTHDAY_SOURCE = "https://www.facebook.com/events/calendar"; private final WebView webView; private FacebookLogInCallback listener; FBImportClient(WebView webview) { this.webView = webview; } @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { if (isHomePage(url)) { view.stopLoading(); internalOnUserLoggedIn(); } } private boolean isHomePage(String url) { return url.startsWith(HTTP + MOBILE_HOME) || url.startsWith(HTTPS + MOBILE_HOME) || url.startsWith(HTTP + DESKTOP_HOME) || url.startsWith(HTTPS + DESKTOP_HOME); } private void internalOnUserLoggedIn() { switchToDesktopBrowsing(); webView.loadUrl(URL_WITH_BIRTHDAY_SOURCE); listener.onUserCredentialsSubmitted(); } private void switchToDesktopBrowsing() { WebSettings settings = webView.getSettings(); settings.setUserAgentString(DESKTOP_USER_AGENT); } @Override public void onPageFinished(WebView view, String url) { if (url.contains(URL_WITH_BIRTHDAY_SOURCE)) { webView.loadUrl("javascript:window.HTMLOUT.processHTML(''+document.getElementsByTagName('html')[0].innerHTML+'');"); } } @Override public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { super.onReceivedError(view, errorCode, description, failingUrl); listener.onError(new FacebookLogInException(description)); } public void setListener(FacebookLogInCallback listener) { this.listener = listener; } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/FacebookImportView.java ================================================ package com.alexstyl.specialdates.facebook.login; import com.alexstyl.specialdates.facebook.UserCredentials; interface FacebookImportView { void showLoading(); void showData(UserCredentials user); void showError(); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/FacebookLogInActivity.java ================================================ package com.alexstyl.specialdates.facebook.login; import android.app.AlarmManager; import android.content.Intent; import android.os.Bundle; import android.support.v7.widget.Toolbar; import android.view.View; import android.view.animation.Animation; import android.view.animation.AnimationUtils; import android.view.animation.BounceInterpolator; import android.webkit.CookieManager; import android.widget.Button; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import com.alexstyl.specialdates.AppComponent; import com.alexstyl.specialdates.CrashAndErrorTracker; import com.alexstyl.specialdates.MementoApplication; import com.alexstyl.specialdates.R; import com.alexstyl.specialdates.ShareAppIntentCreator; import com.alexstyl.specialdates.Strings; import com.alexstyl.specialdates.analytics.Analytics; import com.alexstyl.specialdates.analytics.Screen; import com.alexstyl.specialdates.facebook.FacebookImagePath; import com.alexstyl.specialdates.facebook.FacebookUserSettings; import com.alexstyl.specialdates.facebook.ScreenOrientationLock; import com.alexstyl.specialdates.facebook.UserCredentials; import com.alexstyl.specialdates.facebook.friendimport.FacebookFriendsIntentService; import com.alexstyl.specialdates.facebook.friendimport.FacebookFriendsScheduler; import com.alexstyl.specialdates.images.ImageLoader; import com.alexstyl.specialdates.ui.base.ThemedMementoActivity; import com.novoda.notils.caster.Views; import com.novoda.notils.meta.AndroidUtils; import javax.inject.Inject; import java.net.URI; public class FacebookLogInActivity extends ThemedMementoActivity implements FacebookImportView { private FacebookFriendsScheduler facebookFriendsScheduler; private FacebookWebView webView; private ImageView avatar; private TextView helloView; private TextView moreText; private ScreenOrientationLock orientationLock; private ProgressBar progress; private Button shareButton; private Button closeButton; @Inject Analytics analytics; @Inject Strings stringResource; @Inject CrashAndErrorTracker tracker; @Inject ImageLoader imageLoader; @Inject FacebookUserSettings facebookUserSettings; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); AppComponent applicationModule = ((MementoApplication) getApplication()).getApplicationModule(); applicationModule.inject(this); analytics.trackScreen(Screen.FACEBOOK_LOG_IN); setContentView(R.layout.activity_facebook_log_in); Toolbar toolbar = Views.findById(this, R.id.memento_toolbar); setSupportActionBar(toolbar); avatar = Views.findById(this, R.id.facebook_import_avatar); helloView = Views.findById(this, R.id.facebook_import_hello); moreText = Views.findById(this, R.id.facebook_import_description); progress = Views.findById(this, R.id.progress); shareButton = Views.findById(this, R.id.facebook_import_share); shareButton.setOnClickListener(shareAppIntentOnClick()); closeButton = Views.findById(this, R.id.facebook_import_close); closeButton.setOnClickListener(onCloseButtonPressed()); webView = Views.findById(this, R.id.facebook_import_webview); orientationLock = new ScreenOrientationLock(); facebookFriendsScheduler = new FacebookFriendsScheduler( thisActivity(), (AlarmManager) getSystemService(ALARM_SERVICE) ); webView.setCallback(facebookCallback); UserCredentials userCredentials = facebookUserSettings.retrieveCredentials(); if (savedInstanceState == null || userCredentials.equals(UserCredentials.ANNONYMOUS)) { new CookieResetter(CookieManager.getInstance()).clearAll(); webView.loadLogInPage(); } else { showData(userCredentials); } } private View.OnClickListener onCloseButtonPressed() { return new View.OnClickListener() { @Override public void onClick(View v) { finish(); } }; } private View.OnClickListener shareAppIntentOnClick() { return new View.OnClickListener() { @Override public void onClick(View v) { ShareAppIntentCreator appIntentCreator = new ShareAppIntentCreator(stringResource); Intent intent = appIntentCreator.buildIntent(); startActivity(intent); analytics.trackAppInviteRequested(); } }; } private final FacebookLogInCallback facebookCallback = new FacebookLogInCallback() { @Override public void onUserCredentialsSubmitted() { AndroidUtils.requestHideKeyboard(thisActivity(), webView); showLoading(); } @Override public void onUserLoggedIn(UserCredentials credentials) { fetchFacebookFriends(); showData(credentials); analytics.trackFacebookLoggedIn(); } private void fetchFacebookFriends() { facebookFriendsScheduler.scheduleNext(); startFacebookFetchService(); } private void startFacebookFetchService() { Intent intent = new Intent(thisActivity(), FacebookFriendsIntentService.class); startService(intent); } @Override public void onError(Exception e) { showError(); tracker.track(e); } }; @Override public void showLoading() { orientationLock.lock(thisActivity()); progress.setVisibility(View.VISIBLE); webView.setVisibility(View.GONE); avatar.setVisibility(View.GONE); helloView.setVisibility(View.GONE); moreText.setVisibility(View.GONE); closeButton.setVisibility(View.GONE); shareButton.setVisibility(View.GONE); } @Override public void showData(UserCredentials userCredentials) { progress.setVisibility(View.GONE); webView.setVisibility(View.GONE); avatar.setVisibility(View.VISIBLE); helloView.setVisibility(View.VISIBLE); moreText.setVisibility(View.VISIBLE); closeButton.setVisibility(View.VISIBLE); shareButton.setVisibility(View.VISIBLE); URI uri = FacebookImagePath.INSTANCE.forUid(userCredentials.getUid()); imageLoader .load(uri) .asCircle() .into(avatar); animateAvatarWithBounce(); avatar.setVisibility(View.VISIBLE); String name = userCredentials.getName(); if (name.isEmpty()) { helloView.setText(R.string.Welcome); } else { helloView.setText(getString(R.string.facebook_hi, name)); } } private void animateAvatarWithBounce() { final Animation animation = AnimationUtils.loadAnimation(FacebookLogInActivity.this, R.anim.bounce); animation.setInterpolator(new BounceInterpolator()); avatar.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { avatar.startAnimation(animation); analytics.trackOnAvatarBounce(); } }); avatar.startAnimation(animation); } @Override public void showError() { avatar.setVisibility(View.VISIBLE); avatar.setImageResource(R.drawable.ic_facebook_sad); helloView.setVisibility(View.VISIBLE); helloView.setText(R.string.facebook_error); closeButton.setVisibility(View.VISIBLE); moreText.setVisibility(View.VISIBLE); moreText.setText(R.string.facebook_try_again); progress.setVisibility(View.GONE); webView.setVisibility(View.GONE); shareButton.setVisibility(View.GONE); } @Override protected void onDestroy() { super.onDestroy(); webView.destroy(); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/FacebookLogInCallback.java ================================================ package com.alexstyl.specialdates.facebook.login; import com.alexstyl.specialdates.facebook.UserCredentials; interface FacebookLogInCallback { void onUserLoggedIn(UserCredentials loggedInCredentials); /** * Called when the user has successfully submitted their log in credentials. */ void onUserCredentialsSubmitted(); void onError(Exception e); } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/FacebookLogInException.java ================================================ package com.alexstyl.specialdates.facebook.login; final class FacebookLogInException extends Exception { FacebookLogInException(String message) { super(message); } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/FacebookWebView.java ================================================ package com.alexstyl.specialdates.facebook.login; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.webkit.JavascriptInterface; import android.webkit.WebSettings; import android.webkit.WebView; import com.alexstyl.specialdates.MementoApplication; import com.alexstyl.specialdates.facebook.FacebookUserSettings; import javax.inject.Inject; public class FacebookWebView extends WebView { private FacebookLogInCallback callback; private FBImportClient client; @Inject FacebookUserSettings facebookUserSettings; public FacebookWebView(Context context, AttributeSet attrs) { super(context, attrs); setup(); } public FacebookWebView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); setup(); } private void setup() { ((MementoApplication) getContext().getApplicationContext()).getApplicationModule().inject(this); clearCache(false); setLayerType(View.LAYER_TYPE_SOFTWARE, null); WebSettings settings = getSettings(); settings.setJavaScriptEnabled(true); addJavascriptInterface(new FacebookJavaScriptInterface(), "HTMLOUT"); client = new FBImportClient(this); setWebViewClient(client); } public void loadLogInPage() { loadUrl("https://m.facebook.com/login"); } public void setCallback(FacebookLogInCallback callback) { this.callback = callback; client.setListener(callback); } private class FacebookJavaScriptInterface { private UserCredentialsExtractorTask userCredentialsExtractorTask; @JavascriptInterface @SuppressWarnings("unused") public void processHTML(String html) { if (userCredentialsExtractorTask == null) { userCredentialsExtractorTask = new UserCredentialsExtractorTask(html, facebookUserSettings, callback); userCredentialsExtractorTask.execute(); } } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/facebook/login/UserCredentialsExtractorTask.java ================================================ package com.alexstyl.specialdates.facebook.login; import android.os.AsyncTask; import com.alexstyl.specialdates.facebook.FacebookUserSettings; import com.alexstyl.specialdates.facebook.UserCredentials; import javax.security.auth.login.LoginException; class UserCredentialsExtractorTask extends AsyncTask { private final CredentialsExtractor extractor = new CredentialsExtractor(); private final String pageSource; private final FacebookLogInCallback callback; private final FacebookUserSettings facebookUserSettings; UserCredentialsExtractorTask(String pageSource, FacebookUserSettings facebookUserSettings, FacebookLogInCallback callback) { this.pageSource = pageSource; this.facebookUserSettings = facebookUserSettings; this.callback = callback; } @Override protected UserCredentials doInBackground(Void... params) { UserCredentials userCredentials = extractor.extractCalendarURL(pageSource); if (userCredentials != UserCredentials.ANNONYMOUS) { facebookUserSettings.store(userCredentials); } return userCredentials; } @Override protected void onPostExecute(UserCredentials userCredentials) { if (userCredentials.isAnnonymous()) { callback.onError(new LoginException("Couldn't find extract calendar")); } else { callback.onUserLoggedIn(userCredentials); } } } ================================================ FILE: android_mobile/src/main/java/com/alexstyl/specialdates/home/DonationBannerView.kt ================================================ package com.alexstyl.specialdates.home import android.content.Context import android.graphics.Color import android.support.v7.widget.LinearLayoutCompat import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.LinearLayout import com.alexstyl.specialdates.BuildConfig import com.alexstyl.specialdates.R import com.google.android.gms.ads.AdListener import com.google.android.gms.ads.AdRequest import com.google.android.gms.ads.AdView class DonationBannerView(context: Context, attrs: AttributeSet?) : LinearLayoutCompat(context, attrs) { private var closeView: View? = null private var listener: OnCloseBannerListener? = null override fun onFinishInflate() { super.onFinishInflate() LayoutInflater.from(context).inflate(R.layout.merge_donation_banner_view, this, true) super.setOrientation(LinearLayout.HORIZONTAL) setBackgroundColor(Color.WHITE) val adView = findViewById(R.id.banner_ad) adView.adListener = object : AdListener() { override fun onAdFailedToLoad(p0: Int) { showAsCallToAction() } } adView.loadAd(adRequest()) closeView = findViewById(R.id.banner_close) closeView!!.setOnClickListener { listener?.onCloseButtonPressed() } } private fun showAsCallToAction() { val supportButton = findViewById