diff --git a/PennMobile/build.gradle b/PennMobile/build.gradle index 77e706227..1c8b04e9b 100644 --- a/PennMobile/build.gradle +++ b/PennMobile/build.gradle @@ -99,6 +99,7 @@ dependencies { testImplementation platform(libs.androidx.compose.bom) androidTestImplementation platform(libs.androidx.compose.bom) implementation platform(libs.firebase.bom) + implementation(libs.firebase.messaging) implementation libs.bundles.compose implementation libs.bundles.material diff --git a/PennMobile/src/main/AndroidManifest.xml b/PennMobile/src/main/AndroidManifest.xml index 27cc004da..c41732ad7 100644 --- a/PennMobile/src/main/AndroidManifest.xml +++ b/PennMobile/src/main/AndroidManifest.xml @@ -18,9 +18,11 @@ + - + + + + + @@ -65,6 +72,16 @@ + + + + + if (isGranted) { + // FCM SDK (and your app) can post notifications. + } else { + // TODO: Inform user that that your app will not show notifications. + } + } + override fun onCreate(savedInstanceState: Bundle?) { if (Build.VERSION.SDK_INT > 28) { setTheme(R.style.DarkModeApi29) @@ -91,6 +107,7 @@ class MainActivity : AppCompatActivity() { setTheme(R.style.DarkBackground) } Utils.getCurrentSystemTime() + askNotificationPermission() setSupportActionBar(binding.include.toolbar) fragmentManager = supportFragmentManager @@ -132,6 +149,25 @@ class MainActivity : AppCompatActivity() { } } + private fun askNotificationPermission() { + // This is only necessary for API level >= 33 (TIRAMISU) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + ) { + // FCM SDK (and your app) can post notifications. + } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + // TODO: display an educational UI explaining to the user the features that will be enabled + // by them granting the POST_NOTIFICATION permission. This UI should provide the user + // "OK" and "No thanks" buttons. If the user selects "OK," directly request the permission. + // If the user selects "No thanks," allow the user to continue without notifications. + } else { + // Directly ask for the permission + requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + private fun onExpandableBottomNavigationItemSelected() { binding.include.expandableBottomBar.setOnNavigationItemSelectedListener { item -> val position = @@ -319,6 +355,7 @@ class MainActivity : AppCompatActivity() { private var mStudentLifeRf2: StudentLifeRf2? = null private var mPlatform: Platform? = null private var mCampusExpress: CampusExpress? = null + private var mNotificationAPI: NotificationAPI? = null @JvmStatic val campusExpressInstance: CampusExpress @@ -383,6 +420,31 @@ class MainActivity : AppCompatActivity() { return mStudentLifeRf2!! } + val notificationAPIInstance: NotificationAPI + get() { + if (mNotificationAPI == null) { + val okHttpClient = + OkHttpClient + .Builder() + .connectTimeout(35, TimeUnit.SECONDS) + .readTimeout(35, TimeUnit.SECONDS) + .writeTimeout(35, TimeUnit.SECONDS) + .build() + + val retrofit = + Retrofit + .Builder() + .baseUrl("https://pennmobile.org/api/") + .client(okHttpClient) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build() + mNotificationAPI = retrofit.create(NotificationAPI::class.java) + } + return mNotificationAPI!! + } + @JvmStatic val studentLifeInstance: StudentLife get() { diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/NotificationAPI.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/NotificationAPI.kt new file mode 100644 index 000000000..d6b85d664 --- /dev/null +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/NotificationAPI.kt @@ -0,0 +1,21 @@ +package com.pennapps.labs.pennmobile.api + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.DELETE +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path + +interface NotificationAPI { + @POST("user/notifications/tokens/android/{token}/") + suspend fun sendNotificationToken( + @Header("Authorization") bearerToken: String, + @Path("token") token: String, + ): Response + + @DELETE("user/notifications/tokens/android/{token}/") + suspend fun deleteNotificationToken( + @Path("token") token: String, + ): Response +} diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/fragments/LoginWebviewFragment.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/fragments/LoginWebviewFragment.kt index 904c49cb3..f2c164fd2 100644 --- a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/fragments/LoginWebviewFragment.kt +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/fragments/LoginWebviewFragment.kt @@ -17,6 +17,8 @@ import android.widget.Button import android.widget.LinearLayout import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.crashlytics.FirebaseCrashlytics @@ -29,6 +31,9 @@ import com.pennapps.labs.pennmobile.api.StudentLife import com.pennapps.labs.pennmobile.api.classes.AccessTokenResponse import com.pennapps.labs.pennmobile.api.classes.Account import com.pennapps.labs.pennmobile.api.classes.GetUserResponse +import com.pennapps.labs.pennmobile.api.viewmodels.LoginWebviewViewmodel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import org.apache.commons.lang3.RandomStringUtils import retrofit.Callback import retrofit.RetrofitError @@ -55,6 +60,7 @@ class LoginWebviewFragment : Fragment() { lateinit var platformAuthUrl: String lateinit var clientID: String lateinit var redirectUri: String + private val loginWebviewViewmodel: LoginWebviewViewmodel by viewModels() override fun onCreateView( inflater: LayoutInflater, @@ -206,6 +212,7 @@ class LoginWebviewFragment : Fragment() { editor.putLong(getString(R.string.token_expires_at), currentTime + expiresInInt) editor.apply() getUser(accessToken) + sendNotifToken() } } @@ -260,6 +267,20 @@ class LoginWebviewFragment : Fragment() { } } + private fun sendNotifToken() { + val mNotificationAPI = MainActivity.notificationAPIInstance + + val bearerToken = "Bearer " + sp.getString(getString(R.string.access_token), "").toString() + val notifToken = sp.getString(getString(R.string.notification_token), "").toString() + + Log.d("Notification Token", notifToken) + val notGuest = !sp.getBoolean(mActivity.getString(R.string.guest_mode), false) + + lifecycleScope.launch(Dispatchers.IO) { + loginWebviewViewmodel.sendToken(mNotificationAPI, notGuest, bearerToken, notifToken) + } + } + private fun getCodeChallenge(codeVerifier: String): String { // Hash the code verifier val md = MessageDigest.getInstance("SHA-256") diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/viewmodels/LoginWebviewViewmodel.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/viewmodels/LoginWebviewViewmodel.kt new file mode 100644 index 000000000..b8bf6dfe0 --- /dev/null +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/api/viewmodels/LoginWebviewViewmodel.kt @@ -0,0 +1,29 @@ +package com.pennapps.labs.pennmobile.api.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import com.pennapps.labs.pennmobile.api.NotificationAPI + +// Currently only include logic for notifications, would add more network handling afterwards (TBD) + +class LoginWebviewViewmodel : ViewModel() { + suspend fun sendToken( + mNotificationAPI: NotificationAPI, + notGuest: Boolean, + bearerToken: String, + notifToken: String, + ) { + try { + if (notGuest) { + val response = mNotificationAPI.sendNotificationToken(bearerToken, notifToken) + if (response.isSuccessful) { + Log.i("Notification Token", "Successfully updated token") + } else { + Log.i("Notification Token", "Error updating token: ${response.code()} ${response.message()}") + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/MoreFragment.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/MoreFragment.kt index b530d1514..5289832c2 100644 --- a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/MoreFragment.kt +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/MoreFragment.kt @@ -47,6 +47,21 @@ class MoreFragment : Fragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) + val initials = + PreferenceManager + .getDefaultSharedPreferences(mActivity) + .getString(getString(R.string.initials), null) + if (initials != null && initials.isNotEmpty()) { + binding.initials.text = initials + } else { + binding.profileBackground.setImageDrawable( + ResourcesCompat.getDrawable( + resources, + R.drawable.ic_guest_avatar, + context?.theme, + ), + ) + } childFragmentManager .beginTransaction() .replace(R.id.more_frame, PreferenceFragment()) diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/PreferenceFragment.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/PreferenceFragment.kt index 4a7474557..f331ef6e2 100644 --- a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/PreferenceFragment.kt +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/PreferenceFragment.kt @@ -4,14 +4,18 @@ import android.annotation.SuppressLint import android.app.AlertDialog import android.content.Context import android.content.Intent +import android.content.SharedPreferences import android.net.Uri import android.os.Bundle +import android.util.Log import android.view.View import android.view.ViewGroup import android.webkit.CookieManager import android.widget.TextView import androidx.appcompat.widget.Toolbar import androidx.fragment.app.FragmentTransaction +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager @@ -20,7 +24,10 @@ import com.pennapps.labs.pennmobile.R import com.pennapps.labs.pennmobile.components.dialog.CustomAlertDialogue import com.pennapps.labs.pennmobile.gsr.fragments.PottruckFragment import com.pennapps.labs.pennmobile.home.fragments.NewsFragment +import com.pennapps.labs.pennmobile.more.viewmodels.PreferenceViewModel import com.pennapps.labs.pennmobile.showSneakerToast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * Created by Davies Lumumba Spring 2021 @@ -29,6 +36,7 @@ class PreferenceFragment : PreferenceFragmentCompat() { private lateinit var mContext: Context private lateinit var mActivity: MainActivity private lateinit var toolbar: Toolbar + private val preferenceViewModel: PreferenceViewModel by viewModels() override fun onAttach(context: Context) { super.onAttach(context) @@ -72,27 +80,32 @@ class PreferenceFragment : PreferenceFragmentCompat() { userLoginPref?.onPreferenceClickListener = Preference.OnPreferenceClickListener { if (pennKey != null) { - val dialog = AlertDialog.Builder(context).create() - dialog.setTitle("Log out") - dialog.setMessage("Are you sure you want to log out?") - dialog.setButton("Logout") { dialog, _ -> - CookieManager.getInstance().removeAllCookie() - editor.remove(getString(R.string.penn_password)) - editor.remove(getString(R.string.penn_user)) - editor.remove(getString(R.string.first_name)) - editor.remove(getString(R.string.last_name)) - editor.remove(getString(R.string.email_address)) - editor.remove(getString(R.string.pennkey)) - editor.remove(getString(R.string.accountID)) - editor.remove(getString(R.string.access_token)) - editor.remove(getString(R.string.guest_mode)) - editor.remove(getString(R.string.initials)) - editor.apply() - dialog.cancel() - mActivity.startLoginFragment() - } - // dialog.setButton(2,"Cancel") { dialog, _ -> dialog.cancel() } - dialog.show() + AlertDialog + .Builder(context) + .setTitle("Log Out") + .setMessage("Are you sure you want to log out?") + .setPositiveButton("Logout") { dialog, _ -> + deleteNotifToken(sp) + CookieManager.getInstance().removeAllCookie() + editor.apply { + remove(getString(R.string.penn_password)) + remove(getString(R.string.penn_user)) + remove(getString(R.string.first_name)) + remove(getString(R.string.last_name)) + remove(getString(R.string.email_address)) + remove(getString(R.string.pennkey)) + remove(getString(R.string.accountID)) + remove(getString(R.string.access_token)) + remove(getString(R.string.guest_mode)) + remove(getString(R.string.campus_express_token)) + remove(getString(R.string.campus_token_expires_in)) + remove(getString(R.string.initials)) + } + dialog.dismiss() + mActivity.startLoginFragment() + }.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } + .create() + .show() } else { mActivity.startLoginFragment() } @@ -261,6 +274,20 @@ class PreferenceFragment : PreferenceFragmentCompat() { alert.show() } + private fun deleteNotifToken(sp: SharedPreferences) { + val notifToken = sp.getString(getString(R.string.notification_token), "").toString() + val mNotificationAPI = MainActivity.notificationAPIInstance + Log.i("Notification Token", notifToken) + + lifecycleScope.launch(Dispatchers.IO) { + try { + preferenceViewModel.deleteTokenResponse(mNotificationAPI, notifToken) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + companion object { private const val PENNLABS = "https://pennlabs.org" private const val FEEDBACK = "https://airtable.com/appFRa4NQvNMEbWsA/shrn4VbSQa8QDj8OG" diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/SettingsFragment.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/SettingsFragment.kt index 0760ed6ec..9b6b128f3 100644 --- a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/SettingsFragment.kt +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/fragments/SettingsFragment.kt @@ -4,6 +4,7 @@ import android.app.AlertDialog import android.app.Dialog import android.content.Context import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -89,28 +90,33 @@ class SettingsFragment : PreferenceFragmentCompat() { logInOutButton?.onPreferenceClickListener = Preference.OnPreferenceClickListener { if (pennKey != null) { - val dialog = AlertDialog.Builder(context).create() - dialog.setTitle("Log out") - dialog.setMessage("Are you sure you want to log out?") - dialog.setButton("Logout") { dialog, _ -> - CookieManager.getInstance().removeAllCookie() - editor.remove(getString(R.string.penn_password)) - editor.remove(getString(R.string.penn_user)) - editor.remove(getString(R.string.first_name)) - editor.remove(getString(R.string.last_name)) - editor.remove(getString(R.string.email_address)) - editor.remove(getString(R.string.pennkey)) - editor.remove(getString(R.string.accountID)) - editor.remove(getString(R.string.access_token)) - editor.remove(getString(R.string.guest_mode)) - editor.remove(getString(R.string.campus_express_token)) - editor.remove(getString(R.string.campus_token_expires_in)) - editor.apply() - dialog.cancel() - mActivity.startLoginFragment() - } - dialog.setButton2("Cancel") { dialog, _ -> dialog.cancel() } - dialog.show() + AlertDialog + .Builder(context) + .setTitle("Log out") + .setMessage("Are you sure you want to log out?") + .setPositiveButton("Logout") { dialog, _ -> + Log.d("SettingsFragment", "Logout button clicked in dialog.") + CookieManager.getInstance().removeAllCookie() + editor.apply { + remove(getString(R.string.penn_password)) + remove(getString(R.string.penn_user)) + remove(getString(R.string.first_name)) + remove(getString(R.string.last_name)) + remove(getString(R.string.email_address)) + remove(getString(R.string.pennkey)) + remove(getString(R.string.accountID)) + remove(getString(R.string.access_token)) + remove(getString(R.string.guest_mode)) + remove(getString(R.string.campus_express_token)) + remove(getString(R.string.campus_token_expires_in)) + } + dialog.dismiss() + Log.d("SettingsFragment", "SharedPreferences cleared, navigating to Login.") + mActivity.startLoginFragment() + }.setNegativeButton("Cancel") { dialog, _ -> dialog.cancel() } + .create() + .show() + Log.d("SettingsFragment", "Logout confirmation dialog displayed.") } else { mActivity.startLoginFragment() } diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/viewmodels/PreferenceViewModel.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/viewmodels/PreferenceViewModel.kt new file mode 100644 index 000000000..bb7b366bc --- /dev/null +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/more/viewmodels/PreferenceViewModel.kt @@ -0,0 +1,25 @@ +package com.pennapps.labs.pennmobile.more.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import com.pennapps.labs.pennmobile.api.NotificationAPI + +// Currently only implemented the notification logic, other network logistics to be implemented + +class PreferenceViewModel : ViewModel() { + suspend fun deleteTokenResponse( + mNotificationAPI: NotificationAPI, + notifToken: String, + ) { + try { + val response = mNotificationAPI.deleteNotificationToken(notifToken) + if (response.isSuccessful) { + Log.i("Notification Token", "Successfully deleted token") + } else { + Log.i("Notification Token", "Error deleting token: ${response.code()} ${response.message()}") + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/PennMobile/src/main/java/com/pennapps/labs/pennmobile/notifications/PushNotificationService.kt b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/notifications/PushNotificationService.kt new file mode 100644 index 000000000..29c0d4cd6 --- /dev/null +++ b/PennMobile/src/main/java/com/pennapps/labs/pennmobile/notifications/PushNotificationService.kt @@ -0,0 +1,75 @@ +package com.pennapps.labs.pennmobile.notifications + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.graphics.BitmapFactory +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.pennapps.labs.pennmobile.MainActivity +import com.pennapps.labs.pennmobile.R + +class PushNotificationService : FirebaseMessagingService() { + private lateinit var mSharedPrefs: SharedPreferences + + override fun onNewToken(token: String) { + super.onNewToken(token) + // Update Server/Database + mSharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) + + with(mSharedPrefs.edit()) { + putString("Notification Token", token) + apply() + } + + Log.d("FCM Registration", "Stored Notification token: $token") + } + + override fun onMessageReceived(message: RemoteMessage) { + super.onMessageReceived(message) + + Log.d("Notification Received", "Notification received!") + val title = message.notification?.title + val body = message.notification?.body + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val notificationChannel = + NotificationChannel( + "MAIN_CHANNEL", + "Main Channel", + NotificationManager.IMPORTANCE_HIGH, + ) + notificationManager.createNotificationChannel(notificationChannel) + + val mainActivityIntent = Intent(this, MainActivity::class.java) + mainActivityIntent.apply { + flags += Intent.FLAG_ACTIVITY_NEW_TASK + flags += Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val pendingIntent = PendingIntent.getActivity(this, 0, mainActivityIntent, PendingIntent.FLAG_IMMUTABLE) + val bitMap = BitmapFactory.decodeResource(this.resources, R.drawable.ic_icon) + + val notificationBuilder = + NotificationCompat + .Builder(this, "MAIN_CHANNEL") + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(R.mipmap.ic_launcher_foreground) + .setLargeIcon(bitMap) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setColor(ContextCompat.getColor(this, R.color.penn_red)) + + notificationManager.notify(1, notificationBuilder.build()) + } +} diff --git a/PennMobile/src/main/res/drawable/baseline_circle_notifications_24.xml b/PennMobile/src/main/res/drawable/baseline_circle_notifications_24.xml new file mode 100644 index 000000000..68c2c90a0 --- /dev/null +++ b/PennMobile/src/main/res/drawable/baseline_circle_notifications_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/PennMobile/src/main/res/values/colors.xml b/PennMobile/src/main/res/values/colors.xml index 8613609bb..268201e90 100644 --- a/PennMobile/src/main/res/values/colors.xml +++ b/PennMobile/src/main/res/values/colors.xml @@ -72,5 +72,6 @@ #FF81D4FA #FF039BE5 #FF01579B + #990000 \ No newline at end of file diff --git a/PennMobile/src/main/res/values/strings.xml b/PennMobile/src/main/res/values/strings.xml index f79485c3b..547cd875b 100644 --- a/PennMobile/src/main/res/values/strings.xml +++ b/PennMobile/src/main/res/values/strings.xml @@ -239,4 +239,8 @@ EXAMPLE Add widget This is an app widget description + + + Notification Token + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 98fb1f815..d19bd425d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,9 +18,10 @@ coordinatorlayout = "1.2.0" customalertviewdialogue = "a1fc69d54d" espressoCore = "3.5.0" exifinterface = "1.3.6" -firebaseBom = "31.5.0" +firebaseBom = "33.5.1" firebaseCrashlyticsKtx = "18.6.0" firebaseCrashalytics = "2.9.9" +firebaseMessaging = "24.0.3" glanceAppwidget = "1.1.0" glide = "4.11.0" googleMapsServices = "2.2.0" @@ -103,6 +104,7 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging", version.ref = "firebaseMessaging" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } google-maps-services = { module = "com.google.maps:google-maps-services", version.ref = "googleMapsServices" } joda-time = { module = "joda-time:joda-time", version.ref = "jodaTime" }