Skip to content

Commit 845f6ca

Browse files
committed
Add DEFAULT_MIN_TTL boundary tests for CredentialsManager and SecureCredentialsManager
1 parent ec444ff commit 845f6ca

10 files changed

Lines changed: 268 additions & 29 deletions

File tree

auth0/src/main/java/com/auth0/android/authentication/storage/BaseCredentialsManager.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ public abstract class BaseCredentialsManager internal constructor(
2222
) {
2323
private var _clock: Clock = ClockImpl()
2424

25+
public companion object {
26+
/**
27+
* Default minimum time to live (in seconds) for the access token.
28+
* When retrieving credentials, if the access token has less than this amount of time
29+
* remaining before expiration, it will be automatically renewed.
30+
* This ensures the access token is valid for at least a short window after retrieval,
31+
* preventing downstream API call failures from nearly-expired tokens.
32+
*/
33+
public const val DEFAULT_MIN_TTL: Int = 60
34+
}
35+
2536
/**
2637
* Updates the clock instance used for expiration verification purposes.
2738
* The use of this method can help on situations where the clock comes from an external synced source.
@@ -83,7 +94,7 @@ public abstract class BaseCredentialsManager internal constructor(
8394
public abstract fun getApiCredentials(
8495
audience: String,
8596
scope: String? = null,
86-
minTtl: Int = 0,
97+
minTtl: Int = DEFAULT_MIN_TTL,
8798
parameters: Map<String, String> = emptyMap(),
8899
headers: Map<String, String> = emptyMap(),
89100
callback: Callback<APICredentials, CredentialsManagerException>
@@ -139,7 +150,7 @@ public abstract class BaseCredentialsManager internal constructor(
139150
public abstract suspend fun awaitApiCredentials(
140151
audience: String,
141152
scope: String? = null,
142-
minTtl: Int = 0,
153+
minTtl: Int = DEFAULT_MIN_TTL,
143154
parameters: Map<String, String> = emptyMap(),
144155
headers: Map<String, String> = emptyMap()
145156
): APICredentials

auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
244244
@JvmSynthetic
245245
@Throws(CredentialsManagerException::class)
246246
override suspend fun awaitCredentials(): Credentials {
247-
return awaitCredentials(null, 0)
247+
return awaitCredentials(null, DEFAULT_MIN_TTL)
248248
}
249249

250250
/**
@@ -390,7 +390,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
390390
* @param callback the callback that will receive a valid [Credentials] or the [CredentialsManagerException].
391391
*/
392392
override fun getCredentials(callback: Callback<Credentials, CredentialsManagerException>) {
393-
getCredentials(null, 0, callback)
393+
getCredentials(null, DEFAULT_MIN_TTL, callback)
394394
}
395395

396396
/**
@@ -702,7 +702,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
702702
* @return whether there are valid credentials stored on this manager.
703703
*/
704704
override fun hasValidCredentials(): Boolean {
705-
return hasValidCredentials(0)
705+
return hasValidCredentials(DEFAULT_MIN_TTL.toLong())
706706
}
707707

708708
/**

auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
409409
@JvmSynthetic
410410
@Throws(CredentialsManagerException::class)
411411
override suspend fun awaitCredentials(): Credentials {
412-
return awaitCredentials(null, 0)
412+
return awaitCredentials(null, DEFAULT_MIN_TTL)
413413
}
414414

415415
/**
@@ -579,7 +579,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
579579
override fun getCredentials(
580580
callback: Callback<Credentials, CredentialsManagerException>
581581
) {
582-
getCredentials(null, 0, callback)
582+
getCredentials(null, DEFAULT_MIN_TTL, callback)
583583
}
584584

585585
/**
@@ -779,7 +779,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
779779
* @return whether this manager contains a valid non-expired pair of credentials or not.
780780
*/
781781
override fun hasValidCredentials(): Boolean {
782-
return hasValidCredentials(0)
782+
return hasValidCredentials(DEFAULT_MIN_TTL.toLong())
783783
}
784784

785785
/**

auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ public class SharedPreferencesStorage @JvmOverloads constructor(
7575
sp.edit().remove(name).apply()
7676
}
7777

78+
override fun removeAll() {
79+
sp.edit().clear().apply()
80+
}
81+
7882
private companion object {
7983
private const val SHARED_PREFERENCES_NAME = "com.auth0.authentication.storage"
8084
}

auth0/src/main/java/com/auth0/android/authentication/storage/Storage.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,9 @@ public interface Storage {
7575
* @param name the name of the value to remove.
7676
*/
7777
public fun remove(name: String)
78+
79+
/**
80+
* Removes all values from the storage.
81+
*/
82+
public fun removeAll()
7883
}

auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.auth0.android.authentication.storage
33
import com.auth0.android.NetworkErrorException
44
import com.auth0.android.authentication.AuthenticationAPIClient
55
import com.auth0.android.authentication.AuthenticationException
6+
import com.auth0.android.authentication.storage.BaseCredentialsManager.Companion.DEFAULT_MIN_TTL
67
import com.auth0.android.callback.Callback
78
import com.auth0.android.request.Request
89
import com.auth0.android.request.internal.GsonProvider
@@ -672,7 +673,7 @@ public class CredentialsManagerTest {
672673
Mockito.`when`(
673674
client.renewAuth("refresh_token", "audience")
674675
).thenReturn(request)
675-
val newDate = Date(CredentialsMock.CURRENT_TIME_MS + 1 * 1000)
676+
val newDate = Date(CredentialsMock.CURRENT_TIME_MS + (DEFAULT_MIN_TTL + 10) * 1000L)
676677
val jwtMock = mock<Jwt>()
677678
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
678679
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)
@@ -1770,6 +1771,103 @@ public class CredentialsManagerTest {
17701771
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
17711772
}
17721773

1774+
@Test
1775+
public fun shouldRenewCredentialsViaCallbackWhenTokenExpiresWithinDefaultMinTtl() {
1776+
// Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s)
1777+
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
1778+
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
1779+
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
1780+
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken")
1781+
Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type")
1782+
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
1783+
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
1784+
.thenReturn(expirationTime)
1785+
Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope")
1786+
Mockito.`when`(
1787+
client.renewAuth("refreshToken")
1788+
).thenReturn(request)
1789+
val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS)
1790+
val jwtMock = mock<Jwt>()
1791+
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
1792+
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)
1793+
1794+
val renewedCredentials =
1795+
Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope")
1796+
Mockito.`when`(request.execute()).thenReturn(renewedCredentials)
1797+
// Use no-arg getCredentials which now uses DEFAULT_MIN_TTL
1798+
manager.getCredentials(callback)
1799+
verify(callback).onSuccess(
1800+
credentialsCaptor.capture()
1801+
)
1802+
// Verify renewal was triggered (client.renewAuth was called)
1803+
verify(client).renewAuth("refreshToken")
1804+
val retrievedCredentials = credentialsCaptor.firstValue
1805+
MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue()))
1806+
MatcherAssert.assertThat(retrievedCredentials.idToken, Is.`is`("newId"))
1807+
MatcherAssert.assertThat(retrievedCredentials.accessToken, Is.`is`("newAccess"))
1808+
}
1809+
1810+
@Test
1811+
@ExperimentalCoroutinesApi
1812+
public fun shouldAwaitRenewedCredentialsWhenTokenExpiresWithinDefaultMinTtl(): Unit = runTest {
1813+
// Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s)
1814+
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
1815+
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
1816+
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
1817+
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken")
1818+
Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type")
1819+
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
1820+
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
1821+
.thenReturn(expirationTime)
1822+
Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope")
1823+
Mockito.`when`(
1824+
client.renewAuth("refreshToken")
1825+
).thenReturn(request)
1826+
val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS)
1827+
val jwtMock = mock<Jwt>()
1828+
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
1829+
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)
1830+
1831+
val renewedCredentials =
1832+
Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope")
1833+
Mockito.`when`(request.execute()).thenReturn(renewedCredentials)
1834+
// Use no-arg awaitCredentials which now uses DEFAULT_MIN_TTL
1835+
val result = manager.awaitCredentials()
1836+
// Verify renewal was triggered
1837+
verify(client).renewAuth("refreshToken")
1838+
MatcherAssert.assertThat(result, Is.`is`(Matchers.notNullValue()))
1839+
MatcherAssert.assertThat(result.idToken, Is.`is`("newId"))
1840+
MatcherAssert.assertThat(result.accessToken, Is.`is`("newAccess"))
1841+
}
1842+
1843+
@Test
1844+
public fun shouldNotHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlAndNoRefreshToken() {
1845+
// Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), and no refresh token
1846+
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
1847+
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
1848+
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
1849+
.thenReturn(expirationTime)
1850+
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null)
1851+
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
1852+
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
1853+
// No-arg hasValidCredentials now uses DEFAULT_MIN_TTL, so token expiring in 30s is invalid
1854+
Assert.assertFalse(manager.hasValidCredentials())
1855+
}
1856+
1857+
@Test
1858+
public fun shouldHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlButRefreshTokenAvailable() {
1859+
// Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), but refresh token is available
1860+
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
1861+
Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime)
1862+
Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at"))
1863+
.thenReturn(expirationTime)
1864+
Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken")
1865+
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")
1866+
Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken")
1867+
// Even though token expires within DEFAULT_MIN_TTL, refresh token makes it valid
1868+
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
1869+
}
1870+
17731871
@Test
17741872
public fun shouldNotHaveCredentialsWhenAccessTokenAndIdTokenAreMissing() {
17751873
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn(null)
@@ -1812,7 +1910,7 @@ public class CredentialsManagerTest {
18121910
//now, update the clock and retry
18131911
manager.setClock(object : Clock {
18141912
override fun getCurrentTimeMillis(): Long {
1815-
return CredentialsMock.CURRENT_TIME_MS - 1000
1913+
return CredentialsMock.CURRENT_TIME_MS - (DEFAULT_MIN_TTL * 1000 + 1000)
18161914
}
18171915
})
18181916
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
@@ -1829,7 +1927,6 @@ public class CredentialsManagerTest {
18291927
})
18301928
}
18311929

1832-
18331930
@Test
18341931
public fun shouldAddParametersToRequest() {
18351932
Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken")

auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.auth0.android.Auth0
99
import com.auth0.android.NetworkErrorException
1010
import com.auth0.android.authentication.AuthenticationAPIClient
1111
import com.auth0.android.authentication.AuthenticationException
12+
import com.auth0.android.authentication.storage.BaseCredentialsManager.Companion.DEFAULT_MIN_TTL
1213
import com.auth0.android.callback.Callback
1314
import com.auth0.android.request.Request
1415
import com.auth0.android.request.internal.GsonProvider
@@ -2501,6 +2502,103 @@ public class SecureCredentialsManagerTest {
25012502
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
25022503
}
25032504

2505+
@Test
2506+
public fun shouldRenewCredentialsViaCallbackWhenTokenExpiresWithinDefaultMinTtl() {
2507+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
2508+
localAuthenticationManager.resultCallback.onSuccess(true)
2509+
}
2510+
// Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s)
2511+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 30 * 1000)
2512+
insertTestCredentials(false, true, true, expiresAt, "scope")
2513+
Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at"))
2514+
.thenReturn(expiresAt.time)
2515+
val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS)
2516+
val jwtMock = mock<Jwt>()
2517+
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
2518+
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)
2519+
Mockito.`when`(
2520+
client.renewAuth("refreshToken")
2521+
).thenReturn(request)
2522+
val expectedCredentials =
2523+
Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope")
2524+
Mockito.`when`(request.execute()).thenReturn(expectedCredentials)
2525+
val expectedJson = gson.toJson(expectedCredentials)
2526+
Mockito.`when`(crypto.encrypt(expectedJson.toByteArray()))
2527+
.thenReturn(expectedJson.toByteArray())
2528+
// Use no-arg getCredentials which now uses DEFAULT_MIN_TTL
2529+
manager.getCredentials(callback)
2530+
verify(callback).onSuccess(
2531+
credentialsCaptor.capture()
2532+
)
2533+
// Verify renewal was triggered
2534+
verify(client).renewAuth("refreshToken")
2535+
val retrievedCredentials = credentialsCaptor.firstValue
2536+
MatcherAssert.assertThat(retrievedCredentials, Is.`is`(Matchers.notNullValue()))
2537+
MatcherAssert.assertThat(retrievedCredentials.idToken, Is.`is`("newId"))
2538+
MatcherAssert.assertThat(retrievedCredentials.accessToken, Is.`is`("newAccess"))
2539+
}
2540+
2541+
@Test
2542+
@ExperimentalCoroutinesApi
2543+
public fun shouldAwaitRenewedCredentialsWhenTokenExpiresWithinDefaultMinTtl(): Unit = runTest {
2544+
Mockito.`when`(localAuthenticationManager.authenticate()).then {
2545+
localAuthenticationManager.resultCallback.onSuccess(true)
2546+
}
2547+
// Token expires in 30 seconds, which is within DEFAULT_MIN_TTL (60s)
2548+
val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + 30 * 1000)
2549+
insertTestCredentials(false, true, true, expiresAt, "scope")
2550+
Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at"))
2551+
.thenReturn(expiresAt.time)
2552+
val newDate = Date(CredentialsMock.ONE_HOUR_AHEAD_MS)
2553+
val jwtMock = mock<Jwt>()
2554+
Mockito.`when`(jwtMock.expiresAt).thenReturn(newDate)
2555+
Mockito.`when`(jwtDecoder.decode("newId")).thenReturn(jwtMock)
2556+
Mockito.`when`(
2557+
client.renewAuth("refreshToken")
2558+
).thenReturn(request)
2559+
val expectedCredentials =
2560+
Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope")
2561+
Mockito.`when`(request.execute()).thenReturn(expectedCredentials)
2562+
val expectedJson = gson.toJson(expectedCredentials)
2563+
Mockito.`when`(crypto.encrypt(expectedJson.toByteArray()))
2564+
.thenReturn(expectedJson.toByteArray())
2565+
// Use no-arg awaitCredentials which now uses DEFAULT_MIN_TTL
2566+
val result = manager.awaitCredentials()
2567+
// Verify renewal was triggered
2568+
verify(client).renewAuth("refreshToken")
2569+
MatcherAssert.assertThat(result, Is.`is`(Matchers.notNullValue()))
2570+
MatcherAssert.assertThat(result.idToken, Is.`is`("newId"))
2571+
MatcherAssert.assertThat(result.accessToken, Is.`is`("newAccess"))
2572+
}
2573+
2574+
@Test
2575+
public fun shouldNotHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlAndNoRefreshToken() {
2576+
// Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), and no refresh token
2577+
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
2578+
Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at"))
2579+
.thenReturn(expirationTime)
2580+
Mockito.`when`(storage.retrieveBoolean("com.auth0.credentials_can_refresh"))
2581+
.thenReturn(false)
2582+
Mockito.`when`(storage.retrieveString("com.auth0.credentials"))
2583+
.thenReturn("{\"access_token\":\"accessToken\"}")
2584+
// No-arg hasValidCredentials now uses DEFAULT_MIN_TTL, so token expiring in 30s is invalid
2585+
Assert.assertFalse(manager.hasValidCredentials())
2586+
}
2587+
2588+
@Test
2589+
public fun shouldHaveValidCredentialsWhenTokenExpiresWithinDefaultMinTtlButRefreshTokenAvailable() {
2590+
// Token expires in 30 seconds, within DEFAULT_MIN_TTL (60s), but refresh token is available
2591+
val expirationTime = CredentialsMock.CURRENT_TIME_MS + 30 * 1000
2592+
Mockito.`when`(storage.retrieveLong("com.auth0.credentials_access_token_expires_at"))
2593+
.thenReturn(expirationTime)
2594+
Mockito.`when`(storage.retrieveBoolean("com.auth0.credentials_can_refresh"))
2595+
.thenReturn(true)
2596+
Mockito.`when`(storage.retrieveString("com.auth0.credentials"))
2597+
.thenReturn("{\"access_token\":\"accessToken\", \"refresh_token\":\"refreshToken\"}")
2598+
// Even though token expires within DEFAULT_MIN_TTL, refresh token makes it valid
2599+
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))
2600+
}
2601+
25042602
@Test
25052603
public fun shouldHaveCredentialsWhenTheAliasUsedHasNotBeenMigratedYet() {
25062604
val expirationTime = CredentialsMock.ONE_HOUR_AHEAD_MS
@@ -3334,7 +3432,7 @@ public class SecureCredentialsManagerTest {
33343432
//now, update the clock and retry
33353433
manager.setClock(object : Clock {
33363434
override fun getCurrentTimeMillis(): Long {
3337-
return CredentialsMock.CURRENT_TIME_MS - 1000
3435+
return CredentialsMock.CURRENT_TIME_MS - (DEFAULT_MIN_TTL * 1000 + 1000)
33383436
}
33393437
})
33403438
MatcherAssert.assertThat(manager.hasValidCredentials(), Is.`is`(true))

auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,4 +221,13 @@ public void shouldRemovePreferencesKey() {
221221
verify(sharedPreferencesEditor).apply();
222222
}
223223

224+
@Test
225+
public void shouldRemoveAllPreferencesKeys() {
226+
when(sharedPreferencesEditor.clear()).thenReturn(sharedPreferencesEditor);
227+
SharedPreferencesStorage storage = new SharedPreferencesStorage(context);
228+
storage.removeAll();
229+
verify(sharedPreferencesEditor).clear();
230+
verify(sharedPreferencesEditor).apply();
231+
}
232+
224233
}

0 commit comments

Comments
 (0)