mirror of
https://github.com/bitfireAT/davx5-ose.git
synced 2026-08-07 05:44:54 -04:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
216579680d | ||
|
|
fe8b50c76d | ||
|
|
9ab7b3b105 | ||
|
|
b90ec5b64c | ||
|
|
b197ac7da2 | ||
|
|
9aa58d170f | ||
|
|
553b782339 | ||
|
|
8b6376e01d | ||
|
|
5da229ed88 | ||
|
|
a0bb681f48 | ||
|
|
2e3987e6af | ||
|
|
f72588a691 | ||
|
|
56fc2121f8 | ||
|
|
c863d05f3f | ||
|
|
f0ff12ebb9 | ||
|
|
87cb43b0e6 | ||
|
|
2e073897b8 | ||
|
|
bdc032d1b9 | ||
|
|
d07fcdcddc | ||
|
|
5f2f151e14 | ||
|
|
33ddb06272 | ||
|
|
ef9f13d1d6 | ||
|
|
6b9520287b | ||
|
|
3944ab0222 | ||
|
|
103459464c | ||
|
|
be2d10278d | ||
|
|
f4b864b3d0 | ||
|
|
a576701ee3 | ||
|
|
40bca54c09 | ||
|
|
88efbd7a00 | ||
|
|
292eb3bb52 | ||
|
|
bfddf92a3b | ||
|
|
872bfa2f40 | ||
|
|
73d9dff1b7 | ||
|
|
fd84ff37e5 | ||
|
|
d280ce9d63 | ||
|
|
67c7be900a | ||
|
|
c9cd6de476 | ||
|
|
924ef0243d | ||
|
|
62d9668081 | ||
|
|
ab6fa7ecbf | ||
|
|
74b0e5cd31 | ||
|
|
e2960ab572 | ||
|
|
50bd119871 | ||
|
|
c1f3165f5d | ||
|
|
915cd6ec44 | ||
|
|
73e936aeb5 | ||
|
|
190fc020ae | ||
|
|
c40a6bca2f | ||
|
|
3129fd909f | ||
|
|
31c77a1d57 | ||
|
|
380562bd28 | ||
|
|
80b1cb55c7 | ||
|
|
59dd66383e | ||
|
|
44af04e310 | ||
|
|
adf45cb569 | ||
|
|
ab88020c15 | ||
|
|
e27c6fde08 | ||
|
|
3135af78af | ||
|
|
9200ec89e1 | ||
|
|
b37a2ad0e0 | ||
|
|
1df42350cc | ||
|
|
3dbd5e3d18 | ||
|
|
7fdcb3710b | ||
|
|
2f9f4f1d7b | ||
|
|
9fc3921b32 | ||
|
|
4f4a22a14e | ||
|
|
16c44d8ad3 | ||
|
|
6cd2aa783e | ||
|
|
c2e161dac6 | ||
|
|
5a686a9a4c | ||
|
|
7ba98d1c71 | ||
|
|
fee5ab5064 | ||
|
|
6e1b42e6a4 | ||
|
|
c1ce953e3e | ||
|
|
3a6db13df8 | ||
|
|
d1d771b16a | ||
|
|
f986d9807e | ||
|
|
e3f33a5603 | ||
|
|
702888e9bd | ||
|
|
6b41849cae | ||
|
|
9dfb23b1d2 | ||
|
|
3468473f63 | ||
|
|
bc63559a24 | ||
|
|
3689df1385 | ||
|
|
dd3b326f66 | ||
|
|
e2ca9eb7d7 | ||
|
|
b001be1626 | ||
|
|
e09714af32 | ||
|
|
a4a816296b | ||
|
|
65db706e06 | ||
|
|
abd0eb533e | ||
|
|
323cd48a30 | ||
|
|
07b59fb6be | ||
|
|
3e2e17bdb2 | ||
|
|
73c5c9ef64 | ||
|
|
12261a23ea | ||
|
|
e19b045494 | ||
|
|
076962e5ed | ||
|
|
04306ed919 | ||
|
|
93abef95f7 | ||
|
|
54cc3bec9f | ||
|
|
d4324bcf8e | ||
|
|
eff4438e9f | ||
|
|
1f7298f947 |
No files matched your search
@@ -12,7 +12,6 @@ cache:
|
||||
test:
|
||||
script:
|
||||
- (cd /sdk/emulator; ./emulator @test -no-audio -no-window & wait-for-emulator.sh)
|
||||
- (mkdir -p apk && cd apk && wget -q https://f-droid.org/repo/org.dmfs.tasks_481.apk && adb install org.dmfs.tasks_481.apk)
|
||||
- ./gradlew check mergeAndroidReports
|
||||
artifacts:
|
||||
paths:
|
||||
|
||||
+24
-32
@@ -13,12 +13,12 @@ apply plugin: 'org.jetbrains.dokka-android'
|
||||
|
||||
android {
|
||||
compileSdkVersion 27
|
||||
buildToolsVersion '27.0.1'
|
||||
buildToolsVersion '27.0.3'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "at.bitfire.davdroid"
|
||||
|
||||
versionCode 194
|
||||
versionCode 228
|
||||
buildConfigField "long", "buildTime", System.currentTimeMillis() + "L"
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
|
||||
@@ -33,10 +33,9 @@ android {
|
||||
|
||||
productFlavors {
|
||||
standard {
|
||||
versionName "1.9.6-ose"
|
||||
versionName "1.11.4-beta4-ose"
|
||||
|
||||
buildConfigField "boolean", "customCerts", "true"
|
||||
buildConfigField "boolean", "customCertsUI", "true"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +54,6 @@ android {
|
||||
disable 'ImpliedQuantity', 'MissingQuantity' // quantities from Transifex may vary
|
||||
disable 'MissingTranslation', 'ExtraTranslation' // translations from Transifex are not always up to date
|
||||
disable "OnClick" // doesn't recognize Kotlin onClick methods
|
||||
disable 'Recycle' // doesn't understand Lombok's @Cleanup
|
||||
disable 'RtlEnabled'
|
||||
disable 'RtlHardcoded'
|
||||
disable 'Typos'
|
||||
@@ -71,38 +69,32 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compile project(':cert4android')
|
||||
compile project(':dav4android')
|
||||
compile project(':ical4android')
|
||||
compile project(':vcard4android')
|
||||
implementation project(':cert4android')
|
||||
implementation project(':dav4android')
|
||||
implementation project(':ical4android')
|
||||
implementation project(':vcard4android')
|
||||
|
||||
compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
compile 'com.android.support:appcompat-v7:27.0.2'
|
||||
compile 'com.android.support:cardview-v7:27.0.2'
|
||||
compile 'com.android.support:design:27.0.2'
|
||||
compile 'com.android.support:preference-v14:27.0.2'
|
||||
implementation 'com.android.support:appcompat-v7:27.1.1'
|
||||
implementation 'com.android.support:cardview-v7:27.1.1'
|
||||
implementation 'com.android.support:design:27.1.1'
|
||||
implementation 'com.android.support:preference-v14:27.1.1'
|
||||
|
||||
compile 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
implementation 'com.github.yukuku:ambilwarna:2.0.1'
|
||||
|
||||
compile 'com.squareup.okhttp3:logging-interceptor:3.9.1'
|
||||
compile 'commons-io:commons-io:2.6'
|
||||
compile 'dnsjava:dnsjava:2.1.8'
|
||||
compile 'org.apache.commons:commons-lang3:3.6'
|
||||
compile 'org.apache.commons:commons-collections4:4.1'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:3.10.0'
|
||||
implementation 'commons-io:commons-io:2.6'
|
||||
implementation 'dnsjava:dnsjava:2.1.8'
|
||||
implementation 'org.apache.commons:commons-lang3:3.7'
|
||||
implementation 'org.apache.commons:commons-collections4:4.1'
|
||||
|
||||
// for tests
|
||||
//noinspection GradleDynamicVersion
|
||||
androidTestCompile('com.android.support.test:runner:+') {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
//noinspection GradleDynamicVersion
|
||||
androidTestCompile('com.android.support.test:rules:+') {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
androidTestCompile 'junit:junit:4.12'
|
||||
androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.9.1'
|
||||
androidTestImplementation 'com.android.support.test:runner:1.0.2'
|
||||
androidTestImplementation 'com.android.support.test:rules:1.0.2'
|
||||
androidTestImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:3.10.0'
|
||||
|
||||
testCompile 'junit:junit:4.12'
|
||||
testCompile 'com.squareup.okhttp3:mockwebserver:3.9.1'
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation 'com.squareup.okhttp3:mockwebserver:3.10.0'
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
-dontwarn ezvcard.io.json.** # JSON serializer (for jCards) not used
|
||||
-dontwarn freemarker.** # freemarker templating library (for creating hCards) not used
|
||||
-dontwarn org.jsoup.** # jsoup library (for hCard parsing) not used
|
||||
-keep class ezvcard.property.** { *; } # keep all VCard properties (created at runtime)
|
||||
-keep class ezvcard.property.** { *; } # keep all vCard properties (created at runtime)
|
||||
|
||||
# ical4j: ignore unused dynamic libraries
|
||||
-dontwarn aQute.**
|
||||
@@ -35,6 +35,7 @@
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.Nullable
|
||||
-dontwarn javax.annotation.ParametersAreNonnullByDefault
|
||||
-dontwarn org.conscrypt.**
|
||||
|
||||
# dnsjava
|
||||
-dontwarn sun.net.spi.nameservice.** # not available on Android
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
espressoTest
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.support.test.InstrumentationRegistry.getInstrumentation
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.apache.commons.io.IOUtils
|
||||
import org.apache.commons.lang3.ArrayUtils.contains
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.Socket
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class CustomTlsSocketFactoryTest {
|
||||
|
||||
private lateinit var certMgr: CustomCertManager
|
||||
private lateinit var factory: CustomTlsSocketFactory
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun startServer() {
|
||||
certMgr = CustomCertManager(getInstrumentation().context, false, true)
|
||||
factory = CustomTlsSocketFactory(null, certMgr)
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
server.shutdown()
|
||||
certMgr.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testSendClientCertificate() {
|
||||
var public: X509Certificate? = null
|
||||
javaClass.classLoader.getResourceAsStream("sample.crt").use {
|
||||
public = CertificateFactory.getInstance("X509").generateCertificate(it) as? X509Certificate
|
||||
}
|
||||
assertNotNull(public)
|
||||
|
||||
val keyFactory = KeyFactory.getInstance("RSA")
|
||||
val private = keyFactory.generatePrivate(PKCS8EncodedKeySpec(readResource("sample.key")))
|
||||
assertNotNull(private)
|
||||
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
||||
keyStore.load(null)
|
||||
val alias = "sample"
|
||||
keyStore.setKeyEntry(alias, private, null, arrayOf(public))
|
||||
assertTrue(keyStore.containsAlias(alias))
|
||||
|
||||
val trustManagerFactory = TrustManagerFactory.getInstance("X509")
|
||||
trustManagerFactory.init(null as KeyStore?)
|
||||
val trustManager = trustManagerFactory.trustManagers.first() as X509TrustManager
|
||||
|
||||
val factory = CustomTlsSocketFactory(object: X509ExtendedKeyManager() {
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
|
||||
alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
arrayOf(public).takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
private.takeIf { forAlias == alias }
|
||||
}, trustManager)
|
||||
|
||||
/* known client cert test URLs (thanks!):
|
||||
* - https://prod.idrix.eu/secure/
|
||||
* - https://server.cryptomix.com/secure/
|
||||
*/
|
||||
val client = OkHttpClient.Builder()
|
||||
.sslSocketFactory(factory, trustManager)
|
||||
.build()
|
||||
client.newCall(Request.Builder()
|
||||
.get()
|
||||
.url("https://prod.idrix.eu/secure/")
|
||||
.build()).execute().use { response ->
|
||||
assertTrue(response.isSuccessful)
|
||||
assertTrue(response.body()!!.string().contains("CN=User Cert,O=Internet Widgits Pty Ltd,ST=Some-State,C=CA"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpgradeTLS() {
|
||||
val s = factory.createSocket(server.hostName, server.port)
|
||||
assertTrue(s is SSLSocket)
|
||||
val ssl = s as SSLSocket
|
||||
|
||||
assertFalse(contains(ssl.enabledProtocols, "SSLv3"))
|
||||
assertTrue(contains(ssl.enabledProtocols, "TLSv1"))
|
||||
assertTrue(contains(ssl.enabledProtocols, "TLSv1.1"))
|
||||
assertTrue(contains(ssl.enabledProtocols, "TLSv1.2"))
|
||||
}
|
||||
|
||||
|
||||
private fun readResource(name: String): ByteArray {
|
||||
this.javaClass.classLoader.getResourceAsStream(name).use {
|
||||
return IOUtils.toByteArray(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Socket;
|
||||
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import at.bitfire.cert4android.CustomCertManager;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
import static android.support.test.InstrumentationRegistry.getInstrumentation;
|
||||
import static android.support.test.InstrumentationRegistry.getTargetContext;
|
||||
import static junit.framework.TestCase.assertFalse;
|
||||
import static org.apache.commons.lang3.ArrayUtils.contains;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class SSLSocketFactoryCompatTest {
|
||||
|
||||
CustomCertManager certMgr;
|
||||
SSLSocketFactoryCompat factory;
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Before
|
||||
public void startServer() throws Exception {
|
||||
certMgr = new CustomCertManager(getInstrumentation().getContext(), false, true);
|
||||
factory = new SSLSocketFactoryCompat(certMgr);
|
||||
server.start();
|
||||
}
|
||||
|
||||
@After
|
||||
public void stopServer() throws Exception {
|
||||
server.shutdown();
|
||||
certMgr.close();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testUpgradeTLS() throws IOException {
|
||||
Socket s = factory.createSocket(server.getHostName(), server.getPort());
|
||||
assertTrue(s instanceof SSLSocket);
|
||||
|
||||
SSLSocket ssl = (SSLSocket)s;
|
||||
assertFalse(contains(ssl.getEnabledProtocols(), "SSLv3"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.1"));
|
||||
assertTrue(contains(ssl.getEnabledProtocols(), "TLSv1.2"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
|
||||
import android.content.ContentValues;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CollectionInfoTest {
|
||||
|
||||
HttpClient httpClient;
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
httpClient = new HttpClient.Builder().build();
|
||||
}
|
||||
|
||||
@After
|
||||
public void shutDown() {
|
||||
httpClient.close();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testFromDavResource() throws IOException, HttpException, DavException {
|
||||
// r/w address book
|
||||
server.enqueue(new MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"));
|
||||
|
||||
DavResource dav = new DavResource(httpClient.getOkHttpClient(), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
CollectionInfo info = new CollectionInfo(dav);
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.getType());
|
||||
assertFalse(info.getReadOnly());
|
||||
assertEquals("My Contacts", info.getDisplayName());
|
||||
assertEquals("My Contacts Description", info.getDescription());
|
||||
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(new MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"));
|
||||
|
||||
dav = new DavResource(httpClient.getOkHttpClient(), server.url("/"));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
info = new CollectionInfo(dav);
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
|
||||
assertTrue(info.getReadOnly());
|
||||
assertNull(info.getDisplayName());
|
||||
assertEquals("My Calendar", info.getDescription());
|
||||
assertEquals(0xFFFF0000, (int)info.getColor());
|
||||
assertEquals("tzdata", info.getTimeZone());
|
||||
assertTrue(info.getSupportsVEVENT());
|
||||
assertTrue(info.getSupportsVTODO());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testFromDB() {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(Collections.ID, 1);
|
||||
values.put(Collections.SERVICE_ID, 1);
|
||||
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name());
|
||||
values.put(Collections.URL, "http://example.com");
|
||||
values.put(Collections.READ_ONLY, 1);
|
||||
values.put(Collections.DISPLAY_NAME, "display name");
|
||||
values.put(Collections.DESCRIPTION, "description");
|
||||
values.put(Collections.COLOR, 0xFFFF0000);
|
||||
values.put(Collections.TIME_ZONE, "tzdata");
|
||||
values.put(Collections.SUPPORTS_VEVENT, 1);
|
||||
values.put(Collections.SUPPORTS_VTODO, 1);
|
||||
values.put(Collections.SYNC, 1);
|
||||
|
||||
CollectionInfo info = new CollectionInfo(values);
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.getType());
|
||||
assertEquals(1, (long)info.getId());
|
||||
assertEquals(1, (long)info.getServiceID());
|
||||
assertEquals("http://example.com", info.getUrl());
|
||||
assertTrue(info.getReadOnly());
|
||||
assertEquals("display name", info.getDisplayName());
|
||||
assertEquals("description", info.getDescription());
|
||||
assertEquals(0xFFFF0000, (int)info.getColor());
|
||||
assertEquals("tzdata", info.getTimeZone());
|
||||
assertTrue(info.getSupportsVEVENT());
|
||||
assertTrue(info.getSupportsVTODO());
|
||||
assertTrue(info.getSelected());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.support.test.filters.SmallTest
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CollectionInfoTest {
|
||||
|
||||
private lateinit var httpClient: HttpClient
|
||||
private val server = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
httpClient = HttpClient.Builder().build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun shutDown() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testFromDavResource() {
|
||||
// r/w address book
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CARD:addressbook/></resourcetype>" +
|
||||
" <displayname>My Contacts</displayname>" +
|
||||
" <CARD:addressbook-description>My Contacts Description</CARD:addressbook-description>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME).use {
|
||||
val info = CollectionInfo(it)
|
||||
assertEquals(CollectionInfo.Type.ADDRESS_BOOK, info.type)
|
||||
assertFalse(info.readOnly)
|
||||
assertEquals("My Contacts", info.displayName)
|
||||
assertEquals("My Contacts Description", info.description)
|
||||
}
|
||||
|
||||
// read-only calendar, no display name
|
||||
server.enqueue(MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CAL='urn:ietf:params:xml:ns:caldav' xmlns:ICAL='http://apple.com/ns/ical/'>" +
|
||||
"<response>" +
|
||||
" <href>/</href>" +
|
||||
" <propstat><prop>" +
|
||||
" <resourcetype><collection/><CAL:calendar/></resourcetype>" +
|
||||
" <current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>" +
|
||||
" <CAL:calendar-description>My Calendar</CAL:calendar-description>" +
|
||||
" <CAL:calendar-timezone>tzdata</CAL:calendar-timezone>" +
|
||||
" <ICAL:calendar-color>#ff0000</ICAL:calendar-color>" +
|
||||
" </prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>"))
|
||||
|
||||
DavResource(httpClient.okHttpClient, server.url("/"))
|
||||
.propfind(0, ResourceType.NAME).use {
|
||||
val info = CollectionInfo(it)
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
|
||||
assertTrue(info.readOnly)
|
||||
assertNull(info.displayName)
|
||||
assertEquals("My Calendar", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timeZone)
|
||||
assertTrue(info.supportsVEVENT)
|
||||
assertTrue(info.supportsVTODO)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFromDB() {
|
||||
val values = ContentValues()
|
||||
values.put(Collections.ID, 1)
|
||||
values.put(Collections.SERVICE_ID, 1)
|
||||
values.put(Collections.TYPE, CollectionInfo.Type.CALENDAR.name)
|
||||
values.put(Collections.URL, "http://example.com")
|
||||
values.put(Collections.READ_ONLY, 1)
|
||||
values.put(Collections.DISPLAY_NAME, "display name")
|
||||
values.put(Collections.DESCRIPTION, "description")
|
||||
values.put(Collections.COLOR, 0xFFFF0000)
|
||||
values.put(Collections.TIME_ZONE, "tzdata")
|
||||
values.put(Collections.SUPPORTS_VEVENT, 1)
|
||||
values.put(Collections.SUPPORTS_VTODO, 1)
|
||||
values.put(Collections.SYNC, 1)
|
||||
|
||||
val info = CollectionInfo(values)
|
||||
assertEquals(CollectionInfo.Type.CALENDAR, info.type)
|
||||
assertEquals(1.toLong(), info.id)
|
||||
assertEquals(1.toLong(), info.serviceID)
|
||||
assertEquals(HttpUrl.parse("http://example.com/"), info.url)
|
||||
assertTrue(info.readOnly)
|
||||
assertEquals("display name", info.displayName)
|
||||
assertEquals("description", info.description)
|
||||
assertEquals(0xFFFF0000.toInt(), info.color)
|
||||
assertEquals("tzdata", info.timeZone)
|
||||
assertTrue(info.supportsVEVENT)
|
||||
assertTrue(info.supportsVTODO)
|
||||
assertTrue(info.selected)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
package at.bitfire.davdroid.settings
|
||||
|
||||
import android.content.ServiceConnection
|
||||
import android.support.test.InstrumentationRegistry
|
||||
import android.support.test.InstrumentationRegistry.getTargetContext
|
||||
import at.bitfire.davdroid.App
|
||||
import junit.framework.Assert.*
|
||||
import junit.framework.Assert.assertFalse
|
||||
import junit.framework.Assert.assertTrue
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.setup;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
|
||||
import at.bitfire.dav4android.DavResource;
|
||||
import at.bitfire.dav4android.exception.DavException;
|
||||
import at.bitfire.dav4android.exception.HttpException;
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet;
|
||||
import at.bitfire.dav4android.property.ResourceType;
|
||||
import at.bitfire.davdroid.HttpClient;
|
||||
import at.bitfire.davdroid.log.Logger;
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
|
||||
import static android.support.test.InstrumentationRegistry.getTargetContext;
|
||||
import static junit.framework.TestCase.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class DavResourceFinderTest {
|
||||
|
||||
MockWebServer server = new MockWebServer();
|
||||
|
||||
DavResourceFinder finder;
|
||||
HttpClient client;
|
||||
LoginCredentials credentials;
|
||||
|
||||
private static final String
|
||||
PATH_NO_DAV = "/nodav",
|
||||
|
||||
PATH_CALDAV = "/caldav",
|
||||
PATH_CARDDAV = "/carddav",
|
||||
PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav",
|
||||
|
||||
SUBPATH_PRINCIPAL = "/principal",
|
||||
SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks",
|
||||
SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts";
|
||||
|
||||
@Before
|
||||
public void initServerAndClient() throws Exception {
|
||||
server.setDispatcher(new TestDispatcher());
|
||||
server.start();
|
||||
|
||||
credentials = new LoginCredentials(URI.create("/"), "mock", "12345");
|
||||
finder = new DavResourceFinder(getTargetContext(), credentials);
|
||||
|
||||
client = new HttpClient.Builder()
|
||||
.addAuthentication(null, credentials.getUserName(), credentials.getPassword())
|
||||
.build();
|
||||
}
|
||||
|
||||
@After
|
||||
public void stopServer() throws Exception {
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRememberIfAddressBookOrHomeset() throws IOException, HttpException, DavException {
|
||||
ServiceInfo info;
|
||||
|
||||
// before dav.propfind(), no info is available
|
||||
DavResource dav = new DavResource(client.getOkHttpClient(), server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL));
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(0, info.getCollections().size());
|
||||
assertEquals(0, info.getHomeSets().size());
|
||||
|
||||
// recognize home set
|
||||
dav.propfind(0, AddressbookHomeSet.NAME);
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(0, info.getCollections().size());
|
||||
assertEquals(1, info.getHomeSets().size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "/").uri(), info.getHomeSets().iterator().next());
|
||||
|
||||
// recognize address book
|
||||
dav = new DavResource(client.getOkHttpClient(), server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK));
|
||||
dav.propfind(0, ResourceType.NAME);
|
||||
finder.rememberIfAddressBookOrHomeset(dav, info = new ServiceInfo());
|
||||
assertEquals(1, info.getCollections().size());
|
||||
assertEquals(server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK + "/").uri(), info.getCollections().keySet().iterator().next());
|
||||
assertEquals(0, info.getHomeSets().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testProvidesService() throws IOException {
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV));
|
||||
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetCurrentUserPrincipal() throws IOException, HttpException, DavException {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV));
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
|
||||
);
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV));
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL).uri(),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
|
||||
);
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV));
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
public class TestDispatcher extends Dispatcher {
|
||||
|
||||
@Override
|
||||
public MockResponse dispatch(RecordedRequest rq) throws InterruptedException {
|
||||
if (!checkAuth(rq)) {
|
||||
MockResponse authenticate = new MockResponse().setResponseCode(401);
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"");
|
||||
return authenticate;
|
||||
}
|
||||
|
||||
String path = rq.getPath();
|
||||
|
||||
if ("OPTIONS".equalsIgnoreCase(rq.getMethod())) {
|
||||
String dav = null;
|
||||
if (path.startsWith(PATH_CALDAV))
|
||||
dav = "calendar-access";
|
||||
else if (path.startsWith(PATH_CARDDAV))
|
||||
dav = "addressbook";
|
||||
else if (path.startsWith(PATH_CALDAV_AND_CARDDAV))
|
||||
dav = "calendar-access, addressbook";
|
||||
MockResponse response = new MockResponse().setResponseCode(200);
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav);
|
||||
return response;
|
||||
|
||||
} else if ("PROPFIND".equalsIgnoreCase(rq.getMethod())) {
|
||||
String props = null;
|
||||
switch (path) {
|
||||
case PATH_CALDAV:
|
||||
case PATH_CARDDAV:
|
||||
props = "<current-user-principal><href>" + path + SUBPATH_PRINCIPAL + "</href></current-user-principal>";
|
||||
break;
|
||||
|
||||
case PATH_CARDDAV + SUBPATH_PRINCIPAL:
|
||||
props = "<CARD:addressbook-home-set>" +
|
||||
" <href>" + PATH_CARDDAV + SUBPATH_ADDRESSBOOK_HOMESET + "</href>" +
|
||||
"</CARD:addressbook-home-set>";
|
||||
break;
|
||||
case PATH_CARDDAV + SUBPATH_ADDRESSBOOK:
|
||||
props = "<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>";
|
||||
break;
|
||||
}
|
||||
Logger.log.info("Sending props: " + props);
|
||||
return new MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>" + rq.getPath() + "</href>" +
|
||||
" <propstat><prop>" + props + "</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>");
|
||||
}
|
||||
|
||||
return new MockResponse().setResponseCode(404);
|
||||
}
|
||||
|
||||
private boolean checkAuth(RecordedRequest rq) {
|
||||
return "Basic bW9jazoxMjM0NQ==".equals(rq.getHeader("Authorization"));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.support.test.InstrumentationRegistry.getTargetContext
|
||||
import android.support.test.filters.SmallTest
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.property.AddressbookHomeSet
|
||||
import at.bitfire.dav4android.property.ResourceType
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.URI
|
||||
|
||||
class DavResourceFinderTest {
|
||||
|
||||
companion object {
|
||||
private const val PATH_NO_DAV = "/nodav"
|
||||
private const val PATH_CALDAV = "/caldav"
|
||||
private const val PATH_CARDDAV = "/carddav"
|
||||
private const val PATH_CALDAV_AND_CARDDAV = "/both-caldav-carddav"
|
||||
|
||||
private const val SUBPATH_PRINCIPAL = "/principal"
|
||||
private const val SUBPATH_ADDRESSBOOK_HOMESET = "/addressbooks"
|
||||
private const val SUBPATH_ADDRESSBOOK = "/addressbooks/private-contacts"
|
||||
}
|
||||
|
||||
val server = MockWebServer()
|
||||
|
||||
lateinit var finder: DavResourceFinder
|
||||
lateinit var client: HttpClient
|
||||
lateinit var loginInfo: LoginInfo
|
||||
|
||||
@Before
|
||||
fun initServerAndClient() {
|
||||
server.setDispatcher(TestDispatcher())
|
||||
server.start()
|
||||
|
||||
loginInfo = LoginInfo(URI.create("/"), Credentials("mock", "12345"))
|
||||
finder = DavResourceFinder(getTargetContext(), loginInfo)
|
||||
|
||||
client = HttpClient.Builder()
|
||||
.addAuthentication(null, loginInfo.credentials)
|
||||
.build()
|
||||
}
|
||||
|
||||
@After
|
||||
fun stopServer() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
@SmallTest
|
||||
fun testRememberIfAddressBookOrHomeset() {
|
||||
// recognize home set
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL))
|
||||
.propfind(0, AddressbookHomeSet.NAME).use {
|
||||
ServiceInfo().let { info ->
|
||||
finder.rememberIfAddressBookOrHomeset(it, info)
|
||||
assertEquals(0, info.collections.size)
|
||||
assertEquals(1, info.homeSets.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET/"), info.homeSets.first())
|
||||
}
|
||||
}
|
||||
|
||||
// recognize address book
|
||||
DavResource(client.okHttpClient, server.url(PATH_CARDDAV + SUBPATH_ADDRESSBOOK))
|
||||
.propfind(0, ResourceType.NAME).use {
|
||||
ServiceInfo().let { info ->
|
||||
finder.rememberIfAddressBookOrHomeset(it, info)
|
||||
assertEquals(1, info.collections.size)
|
||||
assertEquals(server.url("$PATH_CARDDAV$SUBPATH_ADDRESSBOOK/"), info.collections.keys.first())
|
||||
assertEquals(0, info.homeSets.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProvidesService() {
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV))
|
||||
assertFalse(finder.providesService(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
assertTrue(finder.providesService(server.url(PATH_CALDAV_AND_CARDDAV), DavResourceFinder.Service.CARDDAV))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetCurrentUserPrincipal() {
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CALDAV))
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_NO_DAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CALDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CALDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CALDAV), DavResourceFinder.Service.CARDDAV))
|
||||
|
||||
assertEquals(
|
||||
server.url(PATH_CARDDAV + SUBPATH_PRINCIPAL),
|
||||
finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CARDDAV)
|
||||
)
|
||||
assertNull(finder.getCurrentUserPrincipal(server.url(PATH_CARDDAV), DavResourceFinder.Service.CALDAV))
|
||||
}
|
||||
|
||||
|
||||
// mock server
|
||||
|
||||
class TestDispatcher: Dispatcher() {
|
||||
|
||||
override fun dispatch(rq: RecordedRequest): MockResponse {
|
||||
if (!checkAuth(rq)) {
|
||||
val authenticate = MockResponse().setResponseCode(401)
|
||||
authenticate.setHeader("WWW-Authenticate", "Basic realm=\"test\"")
|
||||
return authenticate
|
||||
}
|
||||
|
||||
val path = rq.path
|
||||
|
||||
if (rq.method.equals("OPTIONS", true)) {
|
||||
val dav = when {
|
||||
path.startsWith(PATH_CALDAV) -> "calendar-access"
|
||||
path.startsWith(PATH_CARDDAV) -> "addressbook"
|
||||
path.startsWith(PATH_CALDAV_AND_CARDDAV) -> "calendar-access, addressbook"
|
||||
else -> null
|
||||
}
|
||||
val response = MockResponse().setResponseCode(200)
|
||||
if (dav != null)
|
||||
response.addHeader("DAV", dav)
|
||||
return response
|
||||
} else if (rq.method.equals("PROPFIND", true)) {
|
||||
val props: String?
|
||||
when (path) {
|
||||
PATH_CALDAV,
|
||||
PATH_CARDDAV ->
|
||||
props = "<current-user-principal><href>$path$SUBPATH_PRINCIPAL</href></current-user-principal>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_PRINCIPAL ->
|
||||
props = "<CARD:addressbook-home-set>" +
|
||||
" <href>$PATH_CARDDAV$SUBPATH_ADDRESSBOOK_HOMESET</href>" +
|
||||
"</CARD:addressbook-home-set>"
|
||||
|
||||
PATH_CARDDAV + SUBPATH_ADDRESSBOOK ->
|
||||
props = "<resourcetype>" +
|
||||
" <collection/>" +
|
||||
" <CARD:addressbook/>" +
|
||||
"</resourcetype>"
|
||||
|
||||
else -> props = null
|
||||
}
|
||||
Logger.log.info("Sending props: $props")
|
||||
return MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setBody("<multistatus xmlns='DAV:' xmlns:CARD='urn:ietf:params:xml:ns:carddav'>" +
|
||||
"<response>" +
|
||||
" <href>${rq.path}</href>" +
|
||||
" <propstat><prop>$props</prop></propstat>" +
|
||||
"</response>" +
|
||||
"</multistatus>")
|
||||
}
|
||||
|
||||
return MockResponse().setResponseCode(404)
|
||||
}
|
||||
|
||||
private fun checkAuth(rq: RecordedRequest) =
|
||||
rq.getHeader("Authorization") == "Basic bW9jazoxMjM0NQ=="
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEEzCCAfsCAQEwDQYJKoZIhvcNAQEFBQAwRjELMAkGA1UEBhMCQ0ExEzARBgNV
|
||||
BAgMClNvbWUtU3RhdGUxEDAOBgNVBAoMB0NBIENlcnQxEDAOBgNVBAMMB0NBIENl
|
||||
cnQwHhcNMTgwMTEzMjAyOTI5WhcNMTkwMTEzMjAyOTI5WjBZMQswCQYDVQQGEwJD
|
||||
QTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0
|
||||
cyBQdHkgTHRkMRIwEAYDVQQDDAlVc2VyIENlcnQwggEiMA0GCSqGSIb3DQEBAQUA
|
||||
A4IBDwAwggEKAoIBAQDqOyHAeG4psE/f6i/eTfwbhn6j7WaFXxZiSOWwpQZmzRrx
|
||||
MrfkABJCk0X7KNgCaJcmBkG9G1Ri4HfKrxvJFswMXknlq+0ulGBk7oDnZM+pihuX
|
||||
3D9VCWMMkCqYhLCGADj2zB2mkX4LpcMRi6XoOetKURE/vcIy7rSLAtJM6ZRdftfh
|
||||
2ZxnautS1Tyujh9Au3NI/+Of80tT/nA+oBJQeT1fB/ga1OQlZP5kjSaA7IPiIbTz
|
||||
QBO+r898MvqK/lwsvOYnWAp7TY03z+vPfCs0zjijZEl9Wrl0hW6o5db5kU1v5bcr
|
||||
p87hxFJsGD2HIr2y6kvYfL2hn+h9iANyYdRnUgapAgMBAAEwDQYJKoZIhvcNAQEF
|
||||
BQADggIBAHANsiJITedXPyp89lVMEmGY3zKtOqgQ3tqjvjlNt2sdPnj7wmZbmrNd
|
||||
sa90S/UwOn8PzEFOVxYy1BPlljlEjtjmc4OHMcm4P4Zv36uawHilmK8V+zT59gCK
|
||||
ftB5FP2TLFUFi2X9o8J06d0xJRE77uewN155NV4RmPuP4b/tMmeixoQppHqLqEr5
|
||||
lgEUnt3Mh1ctmeFQFJR6lJ01hlB0gdpVHIhzrVLTO3uo8ePLJTmxP6tyKl/HXj9F
|
||||
mpVsKb1kriKwbkGczfw99OUZeUVbTwQOR07r0SrG71B7IuDvxIORnhQc1OUjt7ob
|
||||
wjdaZauAHxpGBRu+hw9Yqaxchk9Gldy1nEjGyyVCD0FU5taXbl8PhBWEDc4U9tI+
|
||||
xVNmPpsSuCsbz3Mjd1YIVRGL99vLrKsQcj+TNM+jJKKRKes3ihl+l/0FwG6UuO7L
|
||||
EvjlUg5hOtYi1D7xuYyMjroGBGh7swYMt6w4eCDbcjzcCkaCi0H2pScM/rLBpDjS
|
||||
LIoGCvZ1LBdi933/iOj1/8dxGZwY6fEgcyiD2n0xAgYIniLWjEZXOMdIK5FNTNga
|
||||
Tswanvp+6Noa4oIu/hl/LXvPMsouaWfSEbRe0Dshi3GpLj3YtEHoN9DHB8bn7jy5
|
||||
34By81GT41m5kq3hWP//x9kSHYSADpbovCbKbElU1qSt6vTVR4nq
|
||||
-----END CERTIFICATE-----
|
||||
Binary file not shown.
@@ -40,6 +40,10 @@
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CALENDAR"/>
|
||||
|
||||
<!-- android.permission-group.LOCATION -->
|
||||
<!-- required since Android 8.1 to get the WiFi name (for "sync in Wifi only" feature) -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
|
||||
<!-- ical4android declares task access permissions -->
|
||||
|
||||
<application
|
||||
@@ -49,26 +53,12 @@
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme"
|
||||
android:theme="@style/AppThemeExt"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<service android:name=".DavService"/>
|
||||
<service android:name=".settings.Settings"/>
|
||||
|
||||
<receiver android:name=".AccountsChangedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".PackageChangedReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_ADDED"/>
|
||||
<action android:name="android.intent.action.PACKAGE_FULLY_REMOVED"/>
|
||||
<data android:scheme="package"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".ui.AccountsActivity"
|
||||
android:label="@string/app_name"
|
||||
@@ -90,10 +80,6 @@
|
||||
android:label="@string/app_settings"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity android:name=".ui.PermissionsActivity"
|
||||
android:label="@string/permissions_title"
|
||||
android:parentActivityName=".ui.AccountsActivity"/>
|
||||
|
||||
<activity
|
||||
android:name=".ui.setup.LoginActivity"
|
||||
android:label="@string/login_title"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<h3>The MIT License (MIT)</h3>
|
||||
|
||||
<p>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:</p>
|
||||
|
||||
<p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>
|
||||
|
||||
<p>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.</p>
|
||||
@@ -9,10 +9,8 @@ package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcel
|
||||
@@ -21,9 +19,11 @@ import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
@@ -31,14 +31,21 @@ import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
/**
|
||||
* Manages settings of an account.
|
||||
*
|
||||
* @throws InvalidAccountException on construction when the account doesn't exist (anymore)
|
||||
*/
|
||||
class AccountSettings(
|
||||
val context: Context,
|
||||
val settings: ISettings,
|
||||
val account: Account
|
||||
@@ -46,45 +53,52 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
val CURRENT_VERSION = 7
|
||||
val KEY_SETTINGS_VERSION = "version"
|
||||
const val CURRENT_VERSION = 8
|
||||
const val KEY_SETTINGS_VERSION = "version"
|
||||
|
||||
val KEY_USERNAME = "user_name"
|
||||
const val KEY_USERNAME = "user_name"
|
||||
const val KEY_CERTIFICATE_ALIAS = "certificate_alias"
|
||||
|
||||
val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
const val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
|
||||
const val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
|
||||
|
||||
/** Time range limitation to the past [in days]
|
||||
value = null default value (DEFAULT_TIME_RANGE_PAST_DAYS)
|
||||
< 0 (-1) no limit
|
||||
>= 0 entries more than n days in the past won't be synchronized
|
||||
*/
|
||||
val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
const val KEY_TIME_RANGE_PAST_DAYS = "time_range_past_days"
|
||||
const val DEFAULT_TIME_RANGE_PAST_DAYS = 90
|
||||
|
||||
/* Whether DAVdroid sets the local calendar color to the value from service DB at every sync
|
||||
value = null (not existing) true (default)
|
||||
"0" false */
|
||||
val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
const val KEY_MANAGE_CALENDAR_COLORS = "manage_calendar_colors"
|
||||
|
||||
/* Whether DAVdroid populates and uses CalendarContract.Colors
|
||||
value = null (not existing) false (default)
|
||||
"1" true */
|
||||
val KEY_EVENT_COLORS = "event_colors"
|
||||
const val KEY_EVENT_COLORS = "event_colors"
|
||||
|
||||
/** Contact group method:
|
||||
value = null (not existing) groups as separate VCards (default)
|
||||
"CATEGORIES" groups are per-contact CATEGORIES
|
||||
*/
|
||||
val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
const val KEY_CONTACT_GROUP_METHOD = "contact_group_method"
|
||||
|
||||
@JvmField
|
||||
val SYNC_INTERVAL_MANUALLY = -1L
|
||||
const val SYNC_INTERVAL_MANUALLY = -1L
|
||||
|
||||
fun initialUserData(userName: String): Bundle {
|
||||
fun initialUserData(credentials: Credentials): Bundle {
|
||||
val bundle = Bundle(2)
|
||||
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
|
||||
bundle.putString(KEY_USERNAME, userName)
|
||||
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword ->
|
||||
bundle.putString(KEY_USERNAME, credentials.userName)
|
||||
Credentials.Type.ClientCertificate ->
|
||||
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
@@ -111,11 +125,17 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
|
||||
// authentication settings
|
||||
|
||||
fun username(): String? = accountManager.getUserData(account, KEY_USERNAME)
|
||||
fun username(userName: String) = accountManager.setUserData(account, KEY_USERNAME, userName)
|
||||
fun credentials() = Credentials(
|
||||
accountManager.getUserData(account, KEY_USERNAME),
|
||||
accountManager.getPassword(account),
|
||||
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
|
||||
)
|
||||
|
||||
fun password(): String? = accountManager.getPassword(account)
|
||||
fun password(password: String) = accountManager.setPassword(account, password)
|
||||
fun credentials(credentials: Credentials) {
|
||||
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
|
||||
accountManager.setPassword(account, credentials.password)
|
||||
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
|
||||
}
|
||||
|
||||
|
||||
// sync. settings
|
||||
@@ -158,11 +178,11 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
|
||||
fun getTimeRangePastDays(): Int? {
|
||||
val strDays = accountManager.getUserData(account, KEY_TIME_RANGE_PAST_DAYS)
|
||||
if (strDays != null) {
|
||||
return if (strDays != null) {
|
||||
val days = Integer.valueOf(strDays)
|
||||
return if (days < 0) null else days
|
||||
if (days < 0) null else days
|
||||
} else
|
||||
return DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
DEFAULT_TIME_RANGE_PAST_DAYS
|
||||
}
|
||||
|
||||
fun setTimeRangePastDays(days: Int?) =
|
||||
@@ -210,6 +230,8 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
try {
|
||||
val updateProc = this::class.java.getDeclaredMethod("update_${fromVersion}_$toVersion")
|
||||
updateProc.invoke(this)
|
||||
|
||||
Logger.log.info("Account version update successful")
|
||||
accountManager.setUserData(account, KEY_SETTINGS_VERSION, toVersion.toString())
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update account settings", e)
|
||||
@@ -217,6 +239,37 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
/**
|
||||
* There is a mistake in this method. [TaskContract.Tasks.SYNC_VERSION] is used to store the
|
||||
* SEQUENCE and should not be used for the eTag.
|
||||
*/
|
||||
private fun update_7_8() {
|
||||
TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)?.let { provider ->
|
||||
// ETag is now in sync_version instead of sync1
|
||||
// UID is now in _uid instead of sync2
|
||||
provider.client.query(TaskProvider.syncAdapterUri(provider.tasksUri(), account),
|
||||
arrayOf(TaskContract.Tasks._ID, TaskContract.Tasks.SYNC1, TaskContract.Tasks.SYNC2),
|
||||
"${TaskContract.Tasks.ACCOUNT_TYPE}=? AND ${TaskContract.Tasks.ACCOUNT_NAME}=?",
|
||||
arrayOf(account.type, account.name), null).use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
val eTag = cursor.getString(1)
|
||||
val uid = cursor.getString(2)
|
||||
val values = ContentValues(4)
|
||||
values.put(TaskContract.Tasks._UID, uid)
|
||||
values.put(TaskContract.Tasks.SYNC_VERSION, eTag)
|
||||
values.putNull(TaskContract.Tasks.SYNC1)
|
||||
values.putNull(TaskContract.Tasks.SYNC2)
|
||||
Logger.log.log(Level.FINER, "Updating task $id", values)
|
||||
provider.client.update(
|
||||
TaskProvider.syncAdapterUri(ContentUris.withAppendedId(provider.tasksUri(), id), account),
|
||||
values, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun update_6_7() {
|
||||
// add calendar colors
|
||||
@@ -238,6 +291,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("ParcelClassLoader")
|
||||
private fun update_5_6() {
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { provider ->
|
||||
val parcel = Parcel.obtain()
|
||||
@@ -255,7 +309,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
val params = parcel.readBundle()
|
||||
val url = params.getString("url")
|
||||
val url = params.getString("url")?.let { HttpUrl.parse(it) }
|
||||
if (url == null)
|
||||
Logger.log.info("No address book URL, ignoring account")
|
||||
else {
|
||||
@@ -265,7 +319,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
info.displayName = account.name
|
||||
Logger.log.log(Level.INFO, "Creating new address book account", url)
|
||||
val addressBookAccount = Account(LocalAddressBook.accountName(account, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url)))
|
||||
if (!accountManager.addAccountExplicitly(addressBookAccount, null, LocalAddressBook.initialUserData(account, info.url.toString())))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
|
||||
// move contacts to new address book
|
||||
@@ -332,7 +386,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)?.let { client ->
|
||||
try {
|
||||
val addrBook = LocalAddressBook(context, account, client)
|
||||
val url = addrBook.getURL()
|
||||
val url = addrBook.url
|
||||
Logger.log.fine("Migrating address book $url")
|
||||
|
||||
// insert CardDAV service
|
||||
@@ -391,7 +445,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
}
|
||||
}
|
||||
|
||||
AndroidTaskList.acquireTaskProvider(context.contentResolver)?.use { provider ->
|
||||
AndroidTaskList.acquireTaskProvider(context)?.use { provider ->
|
||||
try {
|
||||
val taskLists = AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null)
|
||||
for (taskList in taskLists)
|
||||
@@ -445,6 +499,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@SuppressLint("Recycle")
|
||||
private fun update_1_2() {
|
||||
/* - KEY_ADDRESSBOOK_URL ("addressbook_url"),
|
||||
- KEY_ADDRESSBOOK_CTAG ("addressbook_ctag"),
|
||||
@@ -462,16 +517,16 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
|
||||
// until now, ContactsContract.Settings.UNGROUPED_VISIBLE was not set explicitly
|
||||
val values = ContentValues()
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addr.updateSettings(values)
|
||||
addr.settings = values
|
||||
|
||||
val url = accountManager.getUserData(account, "addressbook_url")
|
||||
if (!url.isNullOrEmpty())
|
||||
addr.setURL(url)
|
||||
addr.url = url
|
||||
accountManager.setUserData(account, "addressbook_url", null)
|
||||
|
||||
val cTag = accountManager.getUserData (account, "addressbook_ctag")
|
||||
if (!cTag.isNullOrEmpty())
|
||||
addr.setCTag(cTag)
|
||||
addr.lastSyncState = SyncState(SyncState.Type.CTAG, cTag)
|
||||
accountManager.setUserData(account, "addressbook_ctag", null)
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import java.util.*
|
||||
|
||||
class AccountsChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
|
||||
private val listeners = LinkedList<OnAccountsUpdateListener>()
|
||||
|
||||
@JvmStatic
|
||||
fun registerListener(listener: OnAccountsUpdateListener, callImmediately: Boolean) {
|
||||
listeners += listener
|
||||
if (callImmediately)
|
||||
listener.onAccountsUpdated(null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun unregisterListener(listener: OnAccountsUpdateListener) {
|
||||
listeners -= listener
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AccountManager.LOGIN_ACCOUNTS_CHANGED_ACTION) {
|
||||
val serviceIntent = Intent(context, DavService::class.java)
|
||||
serviceIntent.action = DavService.ACTION_ACCOUNTS_UPDATED
|
||||
context.startService(serviceIntent)
|
||||
|
||||
for (listener in listeners)
|
||||
listener.onAccountsUpdated(null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,26 +10,29 @@ package at.bitfire.davdroid
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.os.Build
|
||||
import android.support.v7.app.AppCompatDelegate
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class App: Application() {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
|
||||
@JvmField val OVERRIDE_PROXY = "override_proxy"
|
||||
@JvmField val OVERRIDE_PROXY_HOST = "override_proxy_host"
|
||||
@JvmField val OVERRIDE_PROXY_PORT = "override_proxy_port"
|
||||
const val DISTRUST_SYSTEM_CERTIFICATES = "distrust_system_certs"
|
||||
const val OVERRIDE_PROXY = "override_proxy"
|
||||
const val OVERRIDE_PROXY_HOST = "override_proxy_host"
|
||||
const val OVERRIDE_PROXY_PORT = "override_proxy_port"
|
||||
|
||||
@JvmField val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
@JvmField val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
const val OVERRIDE_PROXY_HOST_DEFAULT = "localhost"
|
||||
const val OVERRIDE_PROXY_PORT_DEFAULT = 8118
|
||||
|
||||
|
||||
@JvmStatic
|
||||
fun getLauncherBitmap(context: Context): Bitmap? {
|
||||
val drawableLogo = if (android.os.Build.VERSION.SDK_INT >= 21)
|
||||
context.getDrawable(R.mipmap.ic_launcher)
|
||||
@@ -51,6 +54,21 @@ class App: Application() {
|
||||
|
||||
if (Build.VERSION.SDK_INT <= 21)
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
|
||||
NotificationUtils.createChannels(this)
|
||||
|
||||
// don't block UI for some background checks
|
||||
thread {
|
||||
// watch installed/removed apps
|
||||
val tasksFilter = IntentFilter()
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
tasksFilter.addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
tasksFilter.addDataScheme("package")
|
||||
registerReceiver(PackageChangedReceiver(), tasksFilter)
|
||||
|
||||
// check whether a tasks app is currently installed
|
||||
PackageChangedReceiver.updateTaskSync(this)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,19 +5,12 @@
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
package at.bitfire.davdroid;
|
||||
package at.bitfire.davdroid
|
||||
|
||||
object Constants {
|
||||
|
||||
// notification IDs
|
||||
@JvmField val NOTIFICATION_EXTERNAL_FILE_LOGGING = 1
|
||||
@JvmField val NOTIFICATION_REFRESH_COLLECTIONS = 2
|
||||
@JvmField val NOTIFICATION_CONTACTS_SYNC = 10
|
||||
@JvmField val NOTIFICATION_CALENDAR_SYNC = 11
|
||||
@JvmField val NOTIFICATION_TASK_SYNC = 12
|
||||
@JvmField val NOTIFICATION_PERMISSIONS = 20
|
||||
const val DAVDROID_GREEN_RGBA = 0xFF8bc34a.toInt()
|
||||
|
||||
@JvmField
|
||||
val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
|
||||
const val DEFAULT_SYNC_INTERVAL = 4 * 3600L // 4 hours
|
||||
|
||||
}
|
||||
+13
-7
@@ -15,12 +15,15 @@ import java.net.InetAddress
|
||||
import java.net.Socket
|
||||
import java.security.GeneralSecurityException
|
||||
import java.util.*
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
import javax.net.ssl.*
|
||||
|
||||
class SSLSocketFactoryCompat(
|
||||
/**
|
||||
* Custom TLS socket factory with support for
|
||||
* - enabling/disabling algorithms depending on the Android version,
|
||||
* - client certificate authentication
|
||||
*/
|
||||
class CustomTlsSocketFactory(
|
||||
keyManager: KeyManager?,
|
||||
trustManager: X509TrustManager
|
||||
): SSLSocketFactory() {
|
||||
|
||||
@@ -54,7 +57,7 @@ class SSLSocketFactoryCompat(
|
||||
protocols = _protocols.toTypedArray()
|
||||
|
||||
/* set up reasonable cipher suites */
|
||||
val knownCiphers = arrayOf<String>(
|
||||
val knownCiphers = arrayOf(
|
||||
// TLS 1.2
|
||||
"TLS_RSA_WITH_AES_256_GCM_SHA384",
|
||||
"TLS_RSA_WITH_AES_128_GCM_SHA256",
|
||||
@@ -105,7 +108,10 @@ class SSLSocketFactoryCompat(
|
||||
init {
|
||||
try {
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, arrayOf(trustManager), null)
|
||||
sslContext.init(
|
||||
if (keyManager != null) arrayOf(keyManager) else null,
|
||||
arrayOf(trustManager),
|
||||
null)
|
||||
delegate = sslContext.socketFactory
|
||||
} catch (e: GeneralSecurityException) {
|
||||
throw IllegalStateException() // system has no TLS
|
||||
@@ -9,17 +9,19 @@
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Intent
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
@@ -28,11 +30,9 @@ import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.collections4.iterators.IteratorChain
|
||||
import org.apache.commons.collections4.iterators.SingletonIterator
|
||||
@@ -45,9 +45,14 @@ import kotlin.concurrent.thread
|
||||
class DavService: Service() {
|
||||
|
||||
companion object {
|
||||
@JvmField val ACTION_ACCOUNTS_UPDATED = "accountsUpdated"
|
||||
@JvmField val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
|
||||
@JvmField val EXTRA_DAV_SERVICE_ID = "davServiceID"
|
||||
const val ACTION_REFRESH_COLLECTIONS = "refreshCollections"
|
||||
const val EXTRA_DAV_SERVICE_ID = "davServiceID"
|
||||
|
||||
/** Initialize a forced synchronization. Expects intent data
|
||||
to be an URI of this format:
|
||||
contents://<authority>/<account.type>/<account name>
|
||||
**/
|
||||
const val ACTION_FORCE_SYNC = "forceSync"
|
||||
}
|
||||
|
||||
private val runningRefresh = HashSet<Long>()
|
||||
@@ -59,13 +64,20 @@ class DavService: Service() {
|
||||
val id = intent.getLongExtra(EXTRA_DAV_SERVICE_ID, -1)
|
||||
|
||||
when (intent.action) {
|
||||
ACTION_ACCOUNTS_UPDATED ->
|
||||
cleanupAccounts()
|
||||
ACTION_REFRESH_COLLECTIONS ->
|
||||
if (runningRefresh.add(id)) {
|
||||
thread { refreshCollections(id) }
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(id, true) }
|
||||
}
|
||||
|
||||
ACTION_FORCE_SYNC -> {
|
||||
val authority = intent.data.authority
|
||||
val account = Account(
|
||||
intent.data.pathSegments[1],
|
||||
intent.data.pathSegments[0]
|
||||
)
|
||||
forceSync(authority, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,42 +122,15 @@ class DavService: Service() {
|
||||
which actually do the work
|
||||
*/
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun cleanupAccounts() {
|
||||
Logger.log.info("Cleaning up orphaned accounts")
|
||||
|
||||
OpenHelper(this).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val sqlAccountNames = LinkedList<String>()
|
||||
val accountNames = HashSet<String>()
|
||||
val accountManager = AccountManager.get(this)
|
||||
for (account in accountManager.getAccountsByType(getString(R.string.account_type))) {
|
||||
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
|
||||
accountNames += account.name
|
||||
}
|
||||
|
||||
// delete orphaned address book accounts
|
||||
accountManager.getAccountsByType(getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(this, it, null) }
|
||||
.forEach {
|
||||
try {
|
||||
if (!accountNames.contains(it.getMainAccount().name))
|
||||
it.delete()
|
||||
} catch(e: ContactsStorageException) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't get address book main account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
if (sqlAccountNames.isEmpty())
|
||||
db.delete(Services._TABLE, null, null)
|
||||
else
|
||||
db.delete(Services._TABLE, "${Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
|
||||
}
|
||||
private fun forceSync(authority: String, account: Account) {
|
||||
Logger.log.info("Forcing $authority synchronization of $account")
|
||||
val extras = Bundle(2)
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) // manual sync
|
||||
extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) // run immediately (don't queue)
|
||||
ContentResolver.requestSync(account, authority, extras)
|
||||
}
|
||||
|
||||
fun refreshCollections(service: Long) {
|
||||
private fun refreshCollections(service: Long) {
|
||||
OpenHelper(this@DavService).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
@@ -197,23 +182,63 @@ class DavService: Service() {
|
||||
|
||||
/**
|
||||
* Checks if the given URL defines home sets and adds them to the home set list.
|
||||
* @param dav DavResource to check
|
||||
* @param dav DavResource to check
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun queryHomeSets(dav: DavResource) {
|
||||
when (serviceType) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
|
||||
for ((resource, addressbookHomeSet) in dav.findProperties(AddressbookHomeSet.NAME) as List<Pair<DavResource, AddressbookHomeSet>>)
|
||||
for (href in addressbookHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
|
||||
fun queryHomeSets(dav: DavResource, recurse: Boolean = true) {
|
||||
var response: DavResponse? = null
|
||||
try {
|
||||
when (serviceType) {
|
||||
Services.SERVICE_CARDDAV -> {
|
||||
response = dav.propfind(0, AddressbookHomeSet.NAME, GroupMembership.NAME)
|
||||
for ((resource, addressbookHomeSet) in response.searchProperties(AddressbookHomeSet::class.java))
|
||||
for (href in addressbookHomeSet.hrefs)
|
||||
resource.url.resolve(href)?.let { homeSets += UrlUtils.withTrailingSlash(it) }
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
response = dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
|
||||
for ((resource, calendarHomeSet) in response.searchProperties(CalendarHomeSet::class.java))
|
||||
for (href in calendarHomeSet.hrefs)
|
||||
resource.url.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
|
||||
}
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
dav.propfind(0, CalendarHomeSet.NAME, CalendarProxyReadFor.NAME, CalendarProxyWriteFor.NAME, GroupMembership.NAME)
|
||||
for ((resource, calendarHomeSet) in dav.findProperties(CalendarHomeSet.NAME) as List<Pair<DavResource, CalendarHomeSet>>)
|
||||
for (href in calendarHomeSet.hrefs)
|
||||
resource.location.resolve(href)?.let { homeSets.add(UrlUtils.withTrailingSlash(it)) }
|
||||
response = requireNotNull(response)
|
||||
|
||||
if (recurse) {
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
for ((resource, proxyRead) in response.searchProperties(CalendarProxyReadFor::class.java))
|
||||
for (href in proxyRead.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
resource.url.resolve(href)?.let {
|
||||
queryHomeSets(DavResource(dav.httpClient, it), false)
|
||||
}
|
||||
}
|
||||
for ((resource, proxyWrite) in response.searchProperties(CalendarProxyWriteFor::class.java))
|
||||
for (href in proxyWrite.hrefs) {
|
||||
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
|
||||
resource.url.resolve(href)?.let {
|
||||
queryHomeSets(DavResource(dav.httpClient, it), false)
|
||||
}
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
response[GroupMembership::class.java]?.let { groupMembership ->
|
||||
for (href in groupMembership.hrefs) {
|
||||
Logger.log.fine("Principal is member of group $href, checking for home sets")
|
||||
response.url.resolve(href)?.let { url ->
|
||||
try {
|
||||
queryHomeSets(DavResource(dav.httpClient, url), false)
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
} catch (e: DavException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
response?.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,43 +276,14 @@ class DavService: Service() {
|
||||
// refresh home set list (from principal)
|
||||
readPrincipal()?.let { principalUrl ->
|
||||
Logger.log.fine("Querying principal $principalUrl for home sets")
|
||||
val principal = DavResource(httpClient, principalUrl)
|
||||
queryHomeSets(principal)
|
||||
|
||||
// refresh home sets: calendar-proxy-read/write-for
|
||||
for ((resource, proxyRead) in principal.findProperties(CalendarProxyReadFor.NAME) as List<Pair<DavResource, CalendarProxyReadFor>>)
|
||||
for (href in proxyRead.hrefs) {
|
||||
Logger.log.fine("Principal is a read-only proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
for ((resource, proxyWrite) in principal.findProperties(CalendarProxyWriteFor.NAME) as List<Pair<DavResource, CalendarProxyWriteFor>>)
|
||||
for (href in proxyWrite.hrefs) {
|
||||
Logger.log.fine("Principal is a read/write proxy for $href, checking for home sets")
|
||||
resource.location.resolve(href)?.let { queryHomeSets(DavResource(httpClient, it)) }
|
||||
}
|
||||
|
||||
// refresh home sets: direct group memberships
|
||||
(principal.properties[GroupMembership.NAME] as GroupMembership?)?.let { groupMembership ->
|
||||
for (href in groupMembership.hrefs) {
|
||||
Logger.log.fine("Principal is member of group $href, checking for home sets")
|
||||
principal.location.resolve(href)?.let { url ->
|
||||
try {
|
||||
queryHomeSets(DavResource(httpClient, url))
|
||||
} catch(e: HttpException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
} catch(e: DavException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't query member group", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
queryHomeSets(DavResource(httpClient, principalUrl))
|
||||
}
|
||||
|
||||
// remember selected collections
|
||||
val selectedCollections = HashSet<HttpUrl>()
|
||||
collections.values
|
||||
.filter { it.selected }
|
||||
.forEach { (url,_) -> HttpUrl.parse(url)?.let { selectedCollections.add(it) } }
|
||||
.forEach { (url,_) -> selectedCollections.add(url) }
|
||||
|
||||
// now refresh collections (taken from home sets)
|
||||
val itHomeSets = homeSets.iterator()
|
||||
@@ -297,20 +293,21 @@ class DavService: Service() {
|
||||
|
||||
val homeSet = DavResource(httpClient, homeSetUrl)
|
||||
try {
|
||||
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES)
|
||||
val itCollections = IteratorChain<DavResource>(homeSet.members.iterator(), homeSet.related.iterator(), SingletonIterator(homeSet))
|
||||
while (itCollections.hasNext()) {
|
||||
val member = itCollections.next()
|
||||
val info = CollectionInfo(member)
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
homeSet.propfind(1, *CollectionInfo.DAV_PROPERTIES).use { response ->
|
||||
val itCollections = IteratorChain<DavResponse>(response.members.iterator(), response.related.iterator(), SingletonIterator(response))
|
||||
while (itCollections.hasNext()) {
|
||||
val member = itCollections.next()
|
||||
val info = CollectionInfo(member)
|
||||
info.confirmed = true
|
||||
Logger.log.log(Level.FINE, "Found collection", info)
|
||||
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
|
||||
collections[member.location] = info
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type == CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)))
|
||||
collections[member.url] = info
|
||||
}
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.status in arrayOf(403, 404, 410))
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete home set only if it was not accessible (40x)
|
||||
itHomeSets.remove()
|
||||
}
|
||||
@@ -323,17 +320,18 @@ class DavService: Service() {
|
||||
if (!info.confirmed)
|
||||
try {
|
||||
val collection = DavResource(httpClient, url)
|
||||
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES)
|
||||
val info = CollectionInfo(collection)
|
||||
info.confirmed = true
|
||||
collection.propfind(0, *CollectionInfo.DAV_PROPERTIES).use { response ->
|
||||
val info = CollectionInfo(response)
|
||||
info.confirmed = true
|
||||
|
||||
// remove unusable collections
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
|
||||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
|
||||
itCollections.remove()
|
||||
// remove unusable collections
|
||||
if ((serviceType == Services.SERVICE_CARDDAV && info.type != CollectionInfo.Type.ADDRESS_BOOK) ||
|
||||
(serviceType == Services.SERVICE_CALDAV && !arrayOf(CollectionInfo.Type.CALENDAR, CollectionInfo.Type.WEBCAL).contains(info.type)) ||
|
||||
(info.type == CollectionInfo.Type.WEBCAL && info.source == null))
|
||||
itCollections.remove()
|
||||
}
|
||||
} catch(e: HttpException) {
|
||||
if (e.status in arrayOf(403, 404, 410))
|
||||
if (e.code in arrayOf(403, 404, 410))
|
||||
// delete collection only if it was not accessible (40x)
|
||||
itCollections.remove()
|
||||
else
|
||||
@@ -362,19 +360,20 @@ class DavService: Service() {
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't refresh collection list", e)
|
||||
|
||||
val debugIntent = Intent(this@DavService, DebugInfoActivity::class.java)
|
||||
val debugIntent = Intent(this, DebugInfoActivity::class.java)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
debugIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
|
||||
val nm = NotificationUtils.createChannels(this)
|
||||
val notify = NotificationCompat.Builder(this, NotificationUtils.CHANNEL_SYNC_PROBLEMS)
|
||||
val notify = NotificationUtils.newBuilder(this)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setLargeIcon(App.getLauncherBitmap(this))
|
||||
.setContentTitle(getString(R.string.dav_service_refresh_failed))
|
||||
.setContentText(getString(R.string.dav_service_refresh_couldnt_refresh))
|
||||
.setContentIntent(PendingIntent.getActivity(this, 0, debugIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setSubText(account.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
nm.notify(Constants.NOTIFICATION_REFRESH_COLLECTIONS, notify)
|
||||
NotificationManagerCompat.from(this)
|
||||
.notify(service.toString(), NotificationUtils.NOTIFY_REFRESH_COLLECTIONS, notify)
|
||||
} finally {
|
||||
runningRefresh.remove(service)
|
||||
refreshingStatusListeners.forEach { it.get()?.onDavRefreshStatusChanged(service, false) }
|
||||
|
||||
@@ -6,37 +6,54 @@
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import okhttp3.HttpUrl
|
||||
import org.xbill.DNS.Record
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.*
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Some WebDAV and related network utility methods
|
||||
*/
|
||||
object DavUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun ARGBtoCalDAVColor(colorWithAlpha: Int): String {
|
||||
val alpha = (colorWithAlpha shr 24) and 0xFF
|
||||
val color = colorWithAlpha and 0xFFFFFF
|
||||
return String.format("#%06X%02X", color, alpha)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun lastSegmentOfUrl(url: String): String {
|
||||
val httpUrl = HttpUrl.parse(url) ?: throw IllegalArgumentException("url not parsable")
|
||||
|
||||
fun lastSegmentOfUrl(url: HttpUrl): String {
|
||||
// the list returned by HttpUrl.pathSegments() is unmodifiable, so we have to create a copy
|
||||
val segments = LinkedList<String>(httpUrl.pathSegments())
|
||||
Collections.reverse(segments)
|
||||
val segments = LinkedList<String>(url.pathSegments())
|
||||
segments.reverse()
|
||||
|
||||
for (segment in segments)
|
||||
if (segment.isNotEmpty())
|
||||
return segment
|
||||
return segments.firstOrNull { it.isNotEmpty() } ?: "/"
|
||||
}
|
||||
|
||||
return "/"
|
||||
fun prepareLookup(context: Context, lookup: Lookup) {
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
/* Since Android 8, the system properties net.dns1, net.dns2, ... are not available anymore.
|
||||
The current version of dnsjava relies on these properties to find the default name servers,
|
||||
so we have to add the servers explicitly (fortunately, there's an Android API to
|
||||
get the active DNS servers). */
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val activeLink = connectivity.getLinkProperties(connectivity.activeNetwork)
|
||||
val simpleResolvers = activeLink.dnsServers.map {
|
||||
Logger.log.fine("Using DNS server ${it.hostAddress}")
|
||||
val resolver = SimpleResolver()
|
||||
resolver.setAddress(it)
|
||||
resolver
|
||||
}
|
||||
val resolver = ExtendedResolver(simpleResolvers.toTypedArray())
|
||||
lookup.setResolver(resolver)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSRVRecord(records: Array<Record>?): SRVRecord? {
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid;
|
||||
package at.bitfire.davdroid
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.security.KeyChain
|
||||
import at.bitfire.cert4android.CustomCertManager
|
||||
import at.bitfire.dav4android.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4android.Constants
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import okhttp3.Cache
|
||||
import okhttp3.Interceptor
|
||||
@@ -21,45 +24,59 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.tls.OkHostnameVerifier
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.Principal
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.KeyManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509ExtendedKeyManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
class HttpClient private constructor(
|
||||
val okHttpClient: OkHttpClient,
|
||||
private val certManager: CustomCertManager?
|
||||
): Closeable {
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/** [OkHttpClient] singleton to build all clients from */
|
||||
val sharedClient = OkHttpClient.Builder()
|
||||
// set timeouts
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
.addNetworkInterceptor(UserAgentInterceptor)
|
||||
|
||||
.build()!!
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
certManager?.close()
|
||||
}
|
||||
|
||||
class Builder @JvmOverloads constructor(
|
||||
class Builder(
|
||||
val context: Context? = null,
|
||||
val settings: ISettings? = null,
|
||||
accountSettings: AccountSettings? = null,
|
||||
logger: java.util.logging.Logger = Logger.log
|
||||
val logger: java.util.logging.Logger = Logger.log
|
||||
) {
|
||||
var certManager: CustomCertManager? = null
|
||||
private val orig = OkHttpClient.Builder()
|
||||
private var certManager: CustomCertManager? = null
|
||||
private var certificateAlias: String? = null
|
||||
|
||||
private val orig = sharedClient.newBuilder()
|
||||
|
||||
init {
|
||||
// set timeouts
|
||||
orig.connectTimeout(30, TimeUnit.SECONDS)
|
||||
orig.writeTimeout(30, TimeUnit.SECONDS)
|
||||
orig.readTimeout(120, TimeUnit.SECONDS)
|
||||
|
||||
// don't allow redirects by default, because it would break PROPFIND handling
|
||||
orig.followRedirects(false)
|
||||
|
||||
// add User-Agent to every request
|
||||
orig.addNetworkInterceptor(UserAgentInterceptor)
|
||||
|
||||
// add cookie store for non-persistent cookies (some services like Horde use cookies for session tracking)
|
||||
orig.cookieJar(MemoryCookieStore())
|
||||
|
||||
@@ -91,21 +108,18 @@ class HttpClient private constructor(
|
||||
|
||||
context?.let {
|
||||
if (BuildConfig.customCerts)
|
||||
customCertManager(CustomCertManager(context, BuildConfig.customCertsUI, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
|
||||
customCertManager(CustomCertManager(context, true, !settings.getBoolean(App.DISTRUST_SYSTEM_CERTIFICATES, false)))
|
||||
|
||||
// use account settings for authentication
|
||||
accountSettings?.let {
|
||||
val userName = accountSettings.username()
|
||||
val password = accountSettings.password()
|
||||
if (userName != null && password != null)
|
||||
addAuthentication(null, userName, password)
|
||||
addAuthentication(null, it.credentials())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
constructor(context: Context, host: String?, username: String, password: String): this(context) {
|
||||
addAuthentication(host, username, password)
|
||||
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
|
||||
addAuthentication(host, credentials)
|
||||
}
|
||||
|
||||
fun withDiskCache(): Builder {
|
||||
@@ -129,29 +143,85 @@ class HttpClient private constructor(
|
||||
|
||||
fun customCertManager(manager: CustomCertManager) {
|
||||
certManager = manager
|
||||
orig.sslSocketFactory(SSLSocketFactoryCompat(manager), manager)
|
||||
orig.hostnameVerifier(manager.hostnameVerifier(OkHostnameVerifier.INSTANCE))
|
||||
}
|
||||
fun setForeground(foreground: Boolean): Builder {
|
||||
certManager?.appInForeground = foreground
|
||||
return this
|
||||
}
|
||||
|
||||
fun addAuthentication(host: String?, username: String, password: String): Builder {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password)
|
||||
orig .addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
fun addAuthentication(host: String?, credentials: Credentials): Builder {
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword -> {
|
||||
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!)
|
||||
orig .addNetworkInterceptor(authHandler)
|
||||
.authenticator(authHandler)
|
||||
}
|
||||
Credentials.Type.ClientCertificate -> {
|
||||
certificateAlias = credentials.certificateAlias
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun build() = HttpClient(orig.build(), certManager)
|
||||
fun build(): HttpClient {
|
||||
val trustManager = certManager ?: {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as KeyStore?)
|
||||
factory.trustManagers.first() as X509TrustManager
|
||||
}()
|
||||
|
||||
val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
|
||||
?: OkHostnameVerifier.INSTANCE
|
||||
|
||||
var keyManager: KeyManager? = null
|
||||
try {
|
||||
certificateAlias?.let { alias ->
|
||||
// get client certificate and private key
|
||||
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
|
||||
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
|
||||
logger.fine("Using client certificate $alias for authentication (chain length: ${certs.size})")
|
||||
|
||||
// create Android KeyStore (performs key operations without revealing secret data to DAVdroid)
|
||||
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
||||
keyStore.load(null)
|
||||
|
||||
// create KeyManager
|
||||
keyManager = object: X509ExtendedKeyManager() {
|
||||
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
|
||||
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null
|
||||
|
||||
override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
|
||||
arrayOf(alias)
|
||||
|
||||
override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
|
||||
alias
|
||||
|
||||
override fun getCertificateChain(forAlias: String?) =
|
||||
certs.takeIf { forAlias == alias }
|
||||
|
||||
override fun getPrivateKey(forAlias: String?) =
|
||||
key.takeIf { forAlias == alias }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.log(Level.SEVERE, "Couldn't set up client certificate authentication", e)
|
||||
}
|
||||
|
||||
orig.sslSocketFactory(CustomTlsSocketFactory(keyManager, trustManager), trustManager)
|
||||
orig.hostnameVerifier(hostnameVerifier)
|
||||
|
||||
return HttpClient(orig.build(), certManager)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private object UserAgentInterceptor: Interceptor {
|
||||
|
||||
private val userAgentDate = SimpleDateFormat("yyyy/MM/dd", Locale.US).format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp3) Android/${Build.VERSION.RELEASE}"
|
||||
// use Locale.US because numbers may be encoded as non-ASCII characters in other locales
|
||||
private val userAgentDateFormat = SimpleDateFormat("yyyy/MM/dd", Locale.US)
|
||||
private val userAgentDate = userAgentDateFormat.format(Date(BuildConfig.buildTime))
|
||||
private val userAgent = "DAVdroid/${BuildConfig.VERSION_NAME} ($userAgentDate; dav4android; okhttp/${Constants.okHttpVersion}) Android/${Build.VERSION.RELEASE}"
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val locale = Locale.getDefault()
|
||||
|
||||
@@ -27,7 +27,7 @@ class MemoryCookieStore: CookieJar {
|
||||
* This ensures that cookies can be overwritten. [RFC 6265 5.3 Storage Model]
|
||||
* Not thread-safe!
|
||||
*/
|
||||
val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
|
||||
private val storage = MultiKeyMap.multiKeyMap(HashedMap<MultiKey<out String>, Cookie>())!!
|
||||
|
||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||
synchronized(storage) {
|
||||
|
||||
@@ -32,7 +32,6 @@ class PackageChangedReceiver: BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun updateTaskSync(context: Context) {
|
||||
val tasksInstalled = LocalTaskList.tasksProviderAvailable(context)
|
||||
Logger.log.info("Package (un)installed; OpenTasks provider now available = $tasksInstalled")
|
||||
@@ -50,7 +49,7 @@ class PackageChangedReceiver: BroadcastReceiver() {
|
||||
if (ContentResolver.getIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority) <= 0) {
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
|
||||
ContentResolver.setSyncAutomatically(account, TaskProvider.ProviderName.OpenTasks.authority, true)
|
||||
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL.toLong())
|
||||
ContentResolver.addPeriodicSync(account, TaskProvider.ProviderName.OpenTasks.authority, Bundle(), Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
} else
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 0)
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.util.logging.LogRecord
|
||||
|
||||
object LogcatHandler: Handler() {
|
||||
|
||||
private val MAX_LINE_LENGTH = 3000
|
||||
private const val MAX_LINE_LENGTH = 3000
|
||||
|
||||
init {
|
||||
formatter = PlainTextFormatter.LOGCAT
|
||||
|
||||
@@ -15,9 +15,8 @@ import android.content.SharedPreferences
|
||||
import android.os.Process
|
||||
import android.preference.PreferenceManager
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.util.Log
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.ui.AppSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
@@ -29,13 +28,12 @@ import java.util.logging.Level
|
||||
|
||||
object Logger {
|
||||
|
||||
val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage"
|
||||
const val LOG_TO_EXTERNAL_STORAGE = "log_to_external_storage"
|
||||
|
||||
@JvmField
|
||||
val log = java.util.logging.Logger.getLogger("davdroid")!!
|
||||
|
||||
|
||||
lateinit private var preferences: SharedPreferences
|
||||
private lateinit var preferences: SharedPreferences
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
@@ -62,12 +60,11 @@ object Logger {
|
||||
rootLogger.handlers.forEach { rootLogger.removeHandler(it) }
|
||||
rootLogger.addHandler(LogcatHandler)
|
||||
|
||||
val nm = NotificationUtils.createChannels(context)
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
// log to external file according to preferences
|
||||
if (logToFile) {
|
||||
val builder = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_DEBUG)
|
||||
val builder = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_DEBUG)
|
||||
builder .setSmallIcon(R.drawable.ic_sd_storage_notification)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(context.getString(R.string.logging_davdroid_file_logging))
|
||||
.setLocalOnly(true)
|
||||
|
||||
@@ -88,22 +85,22 @@ object Logger {
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, prefIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setStyle(NotificationCompat.BigTextStyle()
|
||||
.bigText(context.getString(R.string.logging_to_external_storage, dir.path)))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.logging_to_external_storage, dir.path)))
|
||||
.setOngoing(true)
|
||||
|
||||
} catch(e: IOException) {
|
||||
log.log(Level.SEVERE, "Couldn't create external log file", e)
|
||||
|
||||
builder .setContentText(context.getString(R.string.logging_couldnt_create_file, e.localizedMessage))
|
||||
val message = context.getString(R.string.logging_couldnt_create_file, e.localizedMessage)
|
||||
builder .setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
}
|
||||
else
|
||||
builder.setContentText(context.getString(R.string.logging_no_external_storage))
|
||||
|
||||
nm.notify(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
nm.notify(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING, builder.build())
|
||||
} else
|
||||
nm.cancel(Constants.NOTIFICATION_EXTERNAL_FILE_LOGGING)
|
||||
nm.cancel(NotificationUtils.NOTIFY_EXTERNAL_FILE_LOGGING)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,10 +19,10 @@ class PlainTextFormatter private constructor(
|
||||
): Formatter() {
|
||||
|
||||
companion object {
|
||||
@JvmField val LOGCAT = PlainTextFormatter(true)
|
||||
@JvmField val DEFAULT = PlainTextFormatter(false)
|
||||
val LOGCAT = PlainTextFormatter(true)
|
||||
val DEFAULT = PlainTextFormatter(false)
|
||||
|
||||
val MAX_MESSAGE_LENGTH = 20000
|
||||
const val MAX_MESSAGE_LENGTH = 20000
|
||||
}
|
||||
|
||||
override fun format(r: LogRecord): String {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
|
||||
package at.bitfire.davdroid.log
|
||||
|
||||
import java.util.logging.Handler;
|
||||
import java.util.logging.LogRecord;
|
||||
import java.util.logging.Handler
|
||||
import java.util.logging.LogRecord
|
||||
|
||||
class StringHandler: Handler() {
|
||||
|
||||
|
||||
@@ -9,13 +9,25 @@
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.content.ContentValues
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import java.io.Serializable
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
data class CollectionInfo @JvmOverloads constructor(
|
||||
val url: String,
|
||||
/**
|
||||
* Represents a WebDAV collection.
|
||||
*
|
||||
* @constructor always appends a trailing slash to the URL
|
||||
*/
|
||||
data class CollectionInfo(
|
||||
|
||||
/**
|
||||
* URL of the collection (including trailing slash)
|
||||
*/
|
||||
val url: HttpUrl,
|
||||
|
||||
var id: Long? = null,
|
||||
var serviceID: Long? = null,
|
||||
@@ -38,7 +50,7 @@ data class CollectionInfo @JvmOverloads constructor(
|
||||
|
||||
// non-persistent properties
|
||||
var confirmed: Boolean = false
|
||||
): Serializable {
|
||||
): Parcelable {
|
||||
|
||||
enum class Type {
|
||||
ADDRESS_BOOK,
|
||||
@@ -48,7 +60,6 @@ data class CollectionInfo @JvmOverloads constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val DAV_PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
@@ -61,8 +72,8 @@ data class CollectionInfo @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
|
||||
constructor(dav: DavResource): this(dav.location.toString()) {
|
||||
(dav.properties[ResourceType.NAME] as ResourceType?)?.let { type ->
|
||||
constructor(dav: DavResponse): this(UrlUtils.withTrailingSlash(dav.url)) {
|
||||
dav[ResourceType::class.java]?.let { type ->
|
||||
when {
|
||||
type.types.contains(ResourceType.ADDRESSBOOK) -> this.type = Type.ADDRESS_BOOK
|
||||
type.types.contains(ResourceType.CALENDAR) -> this.type = Type.CALENDAR
|
||||
@@ -70,33 +81,33 @@ data class CollectionInfo @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
(dav.properties[CurrentUserPrivilegeSet.NAME] as CurrentUserPrivilegeSet?)?.let { privilegeSet ->
|
||||
dav[CurrentUserPrivilegeSet::class.java]?.let { privilegeSet ->
|
||||
readOnly = !privilegeSet.mayWriteContent
|
||||
}
|
||||
|
||||
(dav.properties[DisplayName.NAME] as DisplayName?)?.let {
|
||||
dav[DisplayName::class.java]?.let {
|
||||
if (!it.displayName.isNullOrEmpty())
|
||||
displayName = it.displayName
|
||||
}
|
||||
|
||||
when (type) {
|
||||
Type.ADDRESS_BOOK -> {
|
||||
(dav.properties[AddressbookDescription.NAME] as AddressbookDescription?)?.let { description = it.description }
|
||||
dav[AddressbookDescription::class.java]?.let { description = it.description }
|
||||
}
|
||||
Type.CALENDAR, Type.WEBCAL -> {
|
||||
(dav.properties[CalendarDescription.NAME] as CalendarDescription?)?.let { description = it.description }
|
||||
(dav.properties[CalendarColor.NAME] as CalendarColor?)?.let { color = it.color }
|
||||
(dav.properties[CalendarTimezone.NAME] as CalendarTimezone?)?.let { timeZone = it.vTimeZone }
|
||||
dav[CalendarDescription::class.java]?.let { description = it.description }
|
||||
dav[CalendarColor::class.java]?.let { color = it.color }
|
||||
dav[CalendarTimezone::class.java]?.let { timeZone = it.vTimeZone }
|
||||
|
||||
if (type == Type.CALENDAR) {
|
||||
supportsVEVENT = true
|
||||
supportsVTODO = true
|
||||
(dav.properties[SupportedCalendarComponentSet.NAME] as SupportedCalendarComponentSet?)?.let {
|
||||
dav[SupportedCalendarComponentSet::class.java]?.let {
|
||||
supportsVEVENT = it.supportsEvents
|
||||
supportsVTODO = it.supportsTasks
|
||||
}
|
||||
} else { // Type.WEBCAL
|
||||
(dav.properties[Source.NAME] as Source?)?.let { source = it.hrefs.firstOrNull() }
|
||||
dav[Source::class.java]?.let { source = it.hrefs.firstOrNull() }
|
||||
supportsVEVENT = true
|
||||
}
|
||||
}
|
||||
@@ -104,7 +115,7 @@ data class CollectionInfo @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
|
||||
constructor(values: ContentValues): this(values.getAsString(Collections.URL)) {
|
||||
constructor(values: ContentValues): this(UrlUtils.withTrailingSlash(HttpUrl.parse(values.getAsString(Collections.URL))!!)) {
|
||||
id = values.getAsLong(Collections.ID)
|
||||
serviceID = values.getAsLong(Collections.SERVICE_ID)
|
||||
type = try {
|
||||
@@ -134,7 +145,7 @@ data class CollectionInfo @JvmOverloads constructor(
|
||||
// Collections.SERVICE_ID is never changed
|
||||
type?.let { values.put(Collections.TYPE, it.name) }
|
||||
|
||||
values.put(Collections.URL, url)
|
||||
values.put(Collections.URL, url.toString())
|
||||
values.put(Collections.READ_ONLY, if (readOnly) 1 else 0)
|
||||
values.put(Collections.FORCE_READ_ONLY, if (forceReadOnly) 1 else 0)
|
||||
values.put(Collections.DISPLAY_NAME, displayName)
|
||||
@@ -160,4 +171,79 @@ data class CollectionInfo @JvmOverloads constructor(
|
||||
(i != 0)
|
||||
}
|
||||
|
||||
|
||||
override fun describeContents(): Int = 0
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
fun<T> writeOrNull(value: T?, write: (T) -> Unit) {
|
||||
if (value == null)
|
||||
dest.writeByte(0)
|
||||
else {
|
||||
dest.writeByte(1)
|
||||
write(value)
|
||||
}
|
||||
}
|
||||
|
||||
dest.writeString(url.toString())
|
||||
|
||||
writeOrNull(id, { dest.writeLong(it) })
|
||||
writeOrNull(serviceID, { dest.writeLong(it) })
|
||||
|
||||
dest.writeString(type?.name)
|
||||
|
||||
dest.writeByte(if (readOnly) 1 else 0)
|
||||
dest.writeByte(if (forceReadOnly) 1 else 0)
|
||||
dest.writeString(displayName)
|
||||
dest.writeString(description)
|
||||
writeOrNull(color, { dest.writeInt(it) })
|
||||
|
||||
dest.writeString(timeZone)
|
||||
dest.writeByte(if (supportsVEVENT) 1 else 0)
|
||||
dest.writeByte(if (supportsVTODO) 1 else 0)
|
||||
dest.writeByte(if (selected) 1 else 0)
|
||||
|
||||
dest.writeString(source)
|
||||
|
||||
dest.writeByte(if (confirmed) 1 else 0)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JvmField
|
||||
val CREATOR = object: Parcelable.Creator<CollectionInfo> {
|
||||
override fun createFromParcel(parcel: Parcel): CollectionInfo {
|
||||
fun<T> readOrNull(parcel: Parcel, read: () -> T): T? {
|
||||
return if (parcel.readByte() == 0.toByte())
|
||||
null
|
||||
else
|
||||
read()
|
||||
}
|
||||
|
||||
return CollectionInfo(
|
||||
HttpUrl.parse(parcel.readString())!!,
|
||||
|
||||
readOrNull(parcel, { parcel.readLong() }),
|
||||
readOrNull(parcel, { parcel.readLong() }),
|
||||
|
||||
parcel.readString()?.let { Type.valueOf(it) },
|
||||
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readString(),
|
||||
parcel.readString(),
|
||||
readOrNull(parcel, { parcel.readInt() }),
|
||||
|
||||
parcel.readString(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
parcel.readByte() != 0.toByte(),
|
||||
|
||||
parcel.readString(),
|
||||
|
||||
parcel.readByte() != 0.toByte()
|
||||
)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int) = arrayOfNulls<CollectionInfo>(size)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
class Credentials(
|
||||
val userName: String? = null,
|
||||
val password: String? = null,
|
||||
val certificateAlias: String? = null
|
||||
): Serializable {
|
||||
|
||||
enum class Type {
|
||||
UsernamePassword,
|
||||
ClientCertificate
|
||||
}
|
||||
|
||||
val type: Type
|
||||
|
||||
init {
|
||||
type = when {
|
||||
!certificateAlias.isNullOrEmpty() ->
|
||||
Type.ClientCertificate
|
||||
!userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
|
||||
Type.UsernamePassword
|
||||
else ->
|
||||
throw IllegalArgumentException("Either username/password or certificate alias must be set")
|
||||
}
|
||||
}
|
||||
|
||||
override fun toString() =
|
||||
"Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
|
||||
|
||||
}
|
||||
@@ -17,57 +17,49 @@ import android.preference.PreferenceManager
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.StartupDialogFragment
|
||||
import java.io.Closeable
|
||||
import java.util.logging.Level
|
||||
|
||||
class ServiceDB {
|
||||
|
||||
/*object Settings {
|
||||
@JvmField val _TABLE = "settings"
|
||||
@JvmField val NAME = "setting"
|
||||
@JvmField val VALUE = "value"
|
||||
}*/
|
||||
|
||||
object Services {
|
||||
@JvmField val _TABLE = "services"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val ACCOUNT_NAME = "accountName"
|
||||
@JvmField val SERVICE = "service"
|
||||
@JvmField val PRINCIPAL = "principal"
|
||||
const val _TABLE = "services"
|
||||
const val ID = "_id"
|
||||
const val ACCOUNT_NAME = "accountName"
|
||||
const val SERVICE = "service"
|
||||
const val PRINCIPAL = "principal"
|
||||
|
||||
// allowed values for SERVICE column
|
||||
@JvmField val SERVICE_CALDAV = "caldav"
|
||||
@JvmField val SERVICE_CARDDAV = "carddav"
|
||||
const val SERVICE_CALDAV = "caldav"
|
||||
const val SERVICE_CARDDAV = "carddav"
|
||||
}
|
||||
|
||||
object HomeSets {
|
||||
@JvmField val _TABLE = "homesets"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val SERVICE_ID = "serviceID"
|
||||
@JvmField val URL = "url"
|
||||
const val _TABLE = "homesets"
|
||||
const val ID = "_id"
|
||||
const val SERVICE_ID = "serviceID"
|
||||
const val URL = "url"
|
||||
}
|
||||
|
||||
object Collections {
|
||||
@JvmField val _TABLE = "collections"
|
||||
@JvmField val ID = "_id"
|
||||
@JvmField val TYPE = "type"
|
||||
@JvmField val SERVICE_ID = "serviceID"
|
||||
@JvmField val URL = "url"
|
||||
@JvmField val READ_ONLY = "readOnly"
|
||||
@JvmField val FORCE_READ_ONLY = "forceReadOnly"
|
||||
@JvmField val DISPLAY_NAME = "displayName"
|
||||
@JvmField val DESCRIPTION = "description"
|
||||
@JvmField val COLOR = "color"
|
||||
@JvmField val TIME_ZONE = "timezone"
|
||||
@JvmField val SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
@JvmField val SUPPORTS_VTODO = "supportsVTODO"
|
||||
@JvmField val SOURCE = "source"
|
||||
@JvmField val SYNC = "sync"
|
||||
const val _TABLE = "collections"
|
||||
const val ID = "_id"
|
||||
const val TYPE = "type"
|
||||
const val SERVICE_ID = "serviceID"
|
||||
const val URL = "url"
|
||||
const val READ_ONLY = "readOnly"
|
||||
const val FORCE_READ_ONLY = "forceReadOnly"
|
||||
const val DISPLAY_NAME = "displayName"
|
||||
const val DESCRIPTION = "description"
|
||||
const val COLOR = "color"
|
||||
const val TIME_ZONE = "timezone"
|
||||
const val SUPPORTS_VEVENT = "supportsVEVENT"
|
||||
const val SUPPORTS_VTODO = "supportsVTODO"
|
||||
const val SOURCE = "source"
|
||||
const val SYNC = "sync"
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun onRenameAccount(db: SQLiteDatabase, oldName: String, newName: String) {
|
||||
val values = ContentValues(1)
|
||||
values.put(Services.ACCOUNT_NAME, newName)
|
||||
@@ -79,11 +71,11 @@ class ServiceDB {
|
||||
|
||||
class OpenHelper(
|
||||
val context: Context
|
||||
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), Closeable {
|
||||
): SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION), AutoCloseable {
|
||||
|
||||
companion object {
|
||||
val DATABASE_NAME = "services.db"
|
||||
val DATABASE_VERSION = 4
|
||||
const val DATABASE_NAME = "services.db"
|
||||
const val DATABASE_VERSION = 4
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SQLiteDatabase) {
|
||||
@@ -127,7 +119,7 @@ class ServiceDB {
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
for (upgradeFrom in oldVersion until newVersion) {
|
||||
val upgradeTo = oldVersion + 1
|
||||
val upgradeTo = upgradeFrom + 1
|
||||
Logger.log.info("Upgrading database from version $upgradeFrom to $upgradeTo")
|
||||
try {
|
||||
val upgradeProc = this::class.java.getDeclaredMethod("upgrade_${upgradeFrom}_$upgradeTo", SQLiteDatabase::class.java)
|
||||
@@ -193,7 +185,7 @@ class ServiceDB {
|
||||
// print columns
|
||||
val cols = cursor.columnCount
|
||||
sb.append("\t| ")
|
||||
for (i in 0 .. cols-1)
|
||||
for (i in 0 until cols)
|
||||
sb .append(" ")
|
||||
.append(cursor.getColumnName(i))
|
||||
.append(" |")
|
||||
@@ -202,7 +194,7 @@ class ServiceDB {
|
||||
// print rows
|
||||
while (cursor.moveToNext()) {
|
||||
sb.append("\t| ")
|
||||
for (i in 0 .. cols-1) {
|
||||
for (i in 0 until cols) {
|
||||
sb.append(" ")
|
||||
try {
|
||||
val value = cursor.getString(i)
|
||||
@@ -228,4 +220,4 @@ class ServiceDB {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
|
||||
data class SyncState(
|
||||
val type: Type,
|
||||
val value: String,
|
||||
|
||||
/**
|
||||
* Whether this sync state occurred during an initial sync as described
|
||||
* in RFC 6578, which means the initial sync is not complete yet.
|
||||
*/
|
||||
var initialSync: Boolean? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_TYPE = "type"
|
||||
private const val KEY_VALUE = "value"
|
||||
private const val KEY_INITIAL_SYNC = "initialSync"
|
||||
|
||||
fun fromString(s: String?): SyncState? {
|
||||
if (s == null)
|
||||
return null
|
||||
|
||||
return try {
|
||||
val json = JSONObject(s)
|
||||
SyncState(
|
||||
Type.valueOf(json.getString(KEY_TYPE)),
|
||||
json.getString(KEY_VALUE),
|
||||
try { json.getBoolean(KEY_INITIAL_SYNC) } catch(e: JSONException) { null }
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fromSyncToken(token: SyncToken) =
|
||||
token.token?.let { SyncState(Type.SYNC_TOKEN, it) }
|
||||
|
||||
}
|
||||
|
||||
enum class Type { CTAG, SYNC_TOKEN }
|
||||
|
||||
override fun toString(): String {
|
||||
val json = JSONObject()
|
||||
json.put(KEY_TYPE, type.name)
|
||||
json.put(KEY_VALUE, value)
|
||||
initialSync?.let { json.put(KEY_INITIAL_SYNC, it) }
|
||||
return json.toString()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,23 +6,16 @@
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.model;
|
||||
package at.bitfire.davdroid.model
|
||||
|
||||
import android.provider.ContactsContract.RawContacts;
|
||||
import android.provider.ContactsContract.RawContacts
|
||||
|
||||
object UnknownProperties {
|
||||
|
||||
@JvmField
|
||||
val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
|
||||
const val CONTENT_ITEM_TYPE = "x.davdroid/unknown-properties"
|
||||
|
||||
|
||||
@JvmField
|
||||
val MIMETYPE = RawContacts.Data.MIMETYPE
|
||||
|
||||
@JvmField
|
||||
val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
|
||||
|
||||
@JvmField
|
||||
val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
|
||||
const val MIMETYPE = RawContacts.Data.MIMETYPE
|
||||
const val RAW_CONTACT_ID = RawContacts.Data.RAW_CONTACT_ID
|
||||
const val UNKNOWN_PROPERTIES = RawContacts.Data.DATA1
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.vcard4android.Contact
|
||||
|
||||
interface LocalAddress: LocalResource<Contact> {
|
||||
|
||||
fun resetDeleted()
|
||||
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.TargetApi
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -22,70 +23,66 @@ import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* A local address book. Requires an own Android account, because Android manages contacts per
|
||||
* account and there is no such thing as "address books". So, DAVdroid creates a "DAVdroid
|
||||
* address book" account for every CardDAV address book. These accounts are bound to a
|
||||
* DAVdroid main account.
|
||||
*/
|
||||
class LocalAddressBook(
|
||||
private val context: Context,
|
||||
account: Account,
|
||||
provider: ContentProviderClient?
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalResource> {
|
||||
): AndroidAddressBook<LocalContact, LocalGroup>(account, provider, LocalContact.Factory, LocalGroup.Factory), LocalCollection<LocalAddress> {
|
||||
|
||||
companion object {
|
||||
|
||||
val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
|
||||
val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
|
||||
val USER_DATA_URL = "url"
|
||||
val USER_DATA_READ_ONLY = "read_only"
|
||||
val USER_DATA_CTAG = "ctag"
|
||||
const val USER_DATA_MAIN_ACCOUNT_TYPE = "real_account_type"
|
||||
const val USER_DATA_MAIN_ACCOUNT_NAME = "real_account_name"
|
||||
const val USER_DATA_URL = "url"
|
||||
const val USER_DATA_READ_ONLY = "read_only"
|
||||
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun create(context: Context, provider: ContentProviderClient, mainAccount: Account, info: CollectionInfo): LocalAddressBook {
|
||||
val accountManager = AccountManager.get(context)
|
||||
|
||||
val account = Account(accountName(mainAccount, info), context.getString(R.string.account_type_address_book))
|
||||
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url)))
|
||||
throw ContactsStorageException("Couldn't create address book account")
|
||||
if (!accountManager.addAccountExplicitly(account, null, initialUserData(mainAccount, info.url.toString())))
|
||||
throw IllegalStateException("Couldn't create address book account")
|
||||
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
|
||||
// initialize Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
addressBook.settings = values
|
||||
|
||||
return addressBook
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun find(context: Context, provider: ContentProviderClient, mainAccount: Account?): List<LocalAddressBook> {
|
||||
val accountManager = AccountManager.get(context)
|
||||
fun findAll(context: Context, provider: ContentProviderClient, mainAccount: Account?) = AccountManager.get(context)
|
||||
.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter { mainAccount == null || it.mainAccount == mainAccount }
|
||||
.toList()
|
||||
|
||||
val result = LinkedList<LocalAddressBook>()
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, provider) }
|
||||
.filter { mainAccount == null || it.getMainAccount() == mainAccount }
|
||||
.forEach { result += it }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun accountName(mainAccount: Account, info: CollectionInfo): String {
|
||||
val baos = ByteArrayOutputStream()
|
||||
baos.write(info.url.hashCode())
|
||||
val hash = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
|
||||
val sb = StringBuilder(if (info.displayName.isNullOrEmpty()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
sb .append(" (")
|
||||
.append(mainAccount.name)
|
||||
.append(" ")
|
||||
.append(hash)
|
||||
.append(")")
|
||||
sb.append(" (${mainAccount.name} $hash)")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun initialUserData(mainAccount: Account, url: String): Bundle {
|
||||
val bundle = Bundle(3)
|
||||
bundle.putString(USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
@@ -94,21 +91,97 @@ class LocalAddressBook(
|
||||
return bundle
|
||||
}
|
||||
|
||||
fun mainAccount(context: Context, account: Account): Account =
|
||||
if (account.type == context.getString(R.string.account_type_address_book)) {
|
||||
val manager = AccountManager.get(context)
|
||||
Account(
|
||||
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME),
|
||||
manager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
)
|
||||
} else
|
||||
account
|
||||
|
||||
}
|
||||
|
||||
override val title = account.name!!
|
||||
|
||||
/**
|
||||
* Whether contact groups (LocalGroup resources) are included in query results for
|
||||
* {@link #getAll()}, {@link #getDeleted()}, {@link #getDirty()} and
|
||||
* {@link #getWithoutFileName()}.
|
||||
* Whether contact groups ([LocalGroup]) are included in query results
|
||||
* and are affected by updates/deletes on generic members.
|
||||
*
|
||||
* For instance, if this option is disabled, [findDirty] will find only dirty [LocalContact]s,
|
||||
* but if it is enabled, [findDirty] will find dirty [LocalContact]s and [LocalGroup]s.
|
||||
*/
|
||||
var includeGroups = true
|
||||
|
||||
private var _mainAccount: Account? = null
|
||||
var mainAccount: Account
|
||||
get() {
|
||||
_mainAccount?.let { return it }
|
||||
|
||||
val accountManager = AccountManager.get(context)
|
||||
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw IllegalStateException("Address book doesn't exist anymore")
|
||||
}
|
||||
set(account) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, account.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, account.type)
|
||||
|
||||
_mainAccount = account
|
||||
}
|
||||
|
||||
var url: String
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_URL)
|
||||
?: throw IllegalStateException("Address book has no URL")
|
||||
set(url) = AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
|
||||
|
||||
var readOnly: Boolean
|
||||
get() = AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
|
||||
set(readOnly) = AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override var lastSyncState: SyncState?
|
||||
get() = syncState?.let { SyncState.fromString(String(it)) }
|
||||
set(state) {
|
||||
syncState = state?.toString()?.toByteArray()
|
||||
}
|
||||
|
||||
|
||||
/* operations on the collection (address book) itself */
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalContact.COLUMN_FLAGS, flags)
|
||||
var number = provider!!.update(rawContactsSyncUri(), values, "${RawContacts.DIRTY}=0", null)
|
||||
|
||||
if (includeGroups) {
|
||||
values.clear()
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
number += provider.update(groupsSyncUri(), values, "${Groups.DIRTY}=0", null)
|
||||
}
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int): Int {
|
||||
var number = provider!!.delete(rawContactsSyncUri(),
|
||||
"${RawContacts.DIRTY}=0 AND ${LocalContact.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
if (includeGroups)
|
||||
number += provider.delete(groupsSyncUri(),
|
||||
"${Groups.DIRTY}=0 AND ${LocalGroup.COLUMN_FLAGS}=?", arrayOf(flags.toString()))
|
||||
|
||||
return number
|
||||
}
|
||||
|
||||
fun update(info: CollectionInfo) {
|
||||
val newAccountName = accountName(getMainAccount(), info)
|
||||
val newAccountName = accountName(mainAccount, info)
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
if (account.name != newAccountName && Build.VERSION.SDK_INT >= 21) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val future = accountManager.renameAccount(account, newAccountName, {
|
||||
@@ -119,7 +192,7 @@ class LocalAddressBook(
|
||||
values.put(RawContacts.ACCOUNT_NAME, newAccountName)
|
||||
provider.update(syncAdapterURI(RawContacts.CONTENT_URI), values, "${RawContacts.ACCOUNT_NAME}=?", arrayOf(account.name))
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
} catch (e: RemoteException) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't re-assign contacts to new account name", e)
|
||||
}
|
||||
}, null)
|
||||
@@ -127,147 +200,111 @@ class LocalAddressBook(
|
||||
}
|
||||
|
||||
Constants.log.info("Address book read-only? = ${info.readOnly}")
|
||||
setReadOnly(info.readOnly || info.forceReadOnly)
|
||||
readOnly = info.readOnly || info.forceReadOnly
|
||||
|
||||
// make sure it will still be synchronized when contacts are updated
|
||||
ContentResolver.setSyncAutomatically(account, ContactsContract.AUTHORITY, true)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun delete() {
|
||||
val accountManager = AccountManager.get(context)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
else
|
||||
@Suppress("deprecation")
|
||||
accountManager.removeAccount(account, null, null)
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't remove address book", e)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 22)
|
||||
accountManager.removeAccount(account, null, null, null)
|
||||
else
|
||||
accountManager.removeAccount(account, null, null)
|
||||
}
|
||||
|
||||
|
||||
/* operations on members (contacts/groups) */
|
||||
|
||||
@Throws(ContactsStorageException::class, FileNotFoundException::class)
|
||||
fun findContactByUID(uid: String): LocalContact {
|
||||
val contacts = queryContacts("${AndroidContact.COLUMN_UID}=?", arrayOf(uid))
|
||||
if (contacts.isEmpty())
|
||||
throw FileNotFoundException()
|
||||
return contacts.first()
|
||||
override fun findByName(name: String): LocalAddress? {
|
||||
val result = queryContacts("${AndroidContact.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
return if (includeGroups)
|
||||
result ?: queryGroups("${AndroidGroup.COLUMN_FILENAME}=?", arrayOf(name)).firstOrNull()
|
||||
else
|
||||
result
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getAll(): List<LocalResource> {
|
||||
val all = LinkedList<LocalResource>()
|
||||
all.addAll(queryContacts(null, null))
|
||||
if (includeGroups)
|
||||
all.addAll(queryGroups(null, null))
|
||||
return all
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been deleted locally. (DELETED != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getDeleted(): List<LocalResource> {
|
||||
val deleted = LinkedList<LocalResource>()
|
||||
deleted.addAll(getDeletedContacts())
|
||||
if (includeGroups)
|
||||
deleted.addAll(getDeletedGroups())
|
||||
return deleted
|
||||
override fun findDeleted() =
|
||||
if (includeGroups)
|
||||
findDeletedContacts() + findDeletedGroups()
|
||||
else
|
||||
findDeletedContacts()
|
||||
|
||||
fun findDeletedContacts() = queryContacts("${RawContacts.DELETED}!=0", null)
|
||||
fun findDeletedGroups() = queryGroups("${Groups.DELETED}!=0", null)
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
override fun findDirty() =
|
||||
if (includeGroups)
|
||||
findDirtyContacts() + findDirtyGroups()
|
||||
else
|
||||
findDirtyContacts()
|
||||
|
||||
fun findDirtyContacts() = queryContacts("${RawContacts.DIRTY}!=0", null)
|
||||
fun findDirtyGroups() = queryGroups("${Groups.DIRTY}!=0", null)
|
||||
|
||||
private fun queryContactsGroups(whereContacts: String?, whereArgsContacts: Array<String>?, whereGroups: String?, whereArgsGroups: Array<String>?): List<LocalAddress> {
|
||||
val contacts = queryContacts(whereContacts, whereArgsContacts)
|
||||
return if (includeGroups)
|
||||
contacts + queryGroups(whereGroups, whereArgsGroups)
|
||||
else
|
||||
contacts
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queries all contacts with DIRTY flag and checks whether their data checksum has changed, i.e.
|
||||
* if they're "really dirty" (= data has changed, not only metadata, which is not hashed).
|
||||
* The DIRTY flag is removed from contacts which are not "really dirty", i.e. from contacts
|
||||
* whose contact data checksum has not changed.
|
||||
* @return number of "really dirty" contacts
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun verifyDirty(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("verifyDirty() should not be called on Android != 7")
|
||||
|
||||
var reallyDirty = 0
|
||||
for (contact in getDirtyContacts())
|
||||
try {
|
||||
val lastHash = contact.getLastHashCode()
|
||||
val currentHash = contact.dataHashCode()
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't calculate hash code", e)
|
||||
for (contact in findDirtyContacts()) {
|
||||
val lastHash = contact.getLastHashCode()
|
||||
val currentHash = contact.dataHashCode()
|
||||
if (lastHash == currentHash) {
|
||||
// hash is code still the same, contact is not "really dirty" (only metadata been have changed)
|
||||
Logger.log.log(Level.FINE, "Contact data hash has not changed, resetting dirty flag", contact)
|
||||
contact.resetDirty()
|
||||
} else {
|
||||
Logger.log.log(Level.FINE, "Contact data has changed from hash $lastHash to $currentHash", contact)
|
||||
reallyDirty++
|
||||
}
|
||||
}
|
||||
|
||||
if (includeGroups)
|
||||
reallyDirty += getDirtyGroups().size
|
||||
reallyDirty += findDirtyGroups().size
|
||||
|
||||
return reallyDirty
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts/groups which have been changed locally (DIRTY != 0).
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getDirty(): List<LocalResource> {
|
||||
val dirty = LinkedList<LocalResource>()
|
||||
dirty.addAll(getDirtyContacts())
|
||||
if (includeGroups)
|
||||
dirty.addAll(getDirtyGroups())
|
||||
return dirty
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of local contacts which don't have a file name yet.
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun getWithoutFileName(): List<LocalResource> {
|
||||
val nameless = LinkedList<LocalResource>()
|
||||
nameless.addAll(queryContacts("${AndroidContact.COLUMN_FILENAME} IS NULL", null))
|
||||
if (includeGroups)
|
||||
nameless.addAll(queryGroups("${AndroidGroup.COLUMN_FILENAME} IS NULL", null))
|
||||
return nameless
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDeletedContacts() = queryContacts("${RawContacts.DELETED} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDirtyContacts() = queryContacts("${RawContacts.DIRTY} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDeletedGroups() = queryGroups("${Groups.DELETED} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getDirtyGroups() = queryGroups("${Groups.DIRTY} != 0", null)
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getByGroupMembership(groupID: Long): List<LocalContact> {
|
||||
try {
|
||||
val ids = HashSet<Long>()
|
||||
|
||||
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
|
||||
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
|
||||
null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
ids += cursor.getLong(0)
|
||||
}
|
||||
|
||||
return ids.map { id -> LocalContact(this, id, null, null) }
|
||||
} catch (e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't query contacts", e)
|
||||
val ids = HashSet<Long>()
|
||||
provider!!.query(syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(RawContacts.Data.RAW_CONTACT_ID),
|
||||
"(${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?) OR (${CachedGroupMembership.MIMETYPE}=? AND ${CachedGroupMembership.GROUP_ID}=?)",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, groupID.toString(), CachedGroupMembership.CONTENT_ITEM_TYPE, groupID.toString()),
|
||||
null)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
ids += cursor.getLong(0)
|
||||
}
|
||||
|
||||
return ids.map { findContactByID(it) }
|
||||
}
|
||||
|
||||
|
||||
@@ -276,29 +313,23 @@ class LocalAddressBook(
|
||||
/**
|
||||
* Finds the first group with the given title. If there is no group with this
|
||||
* title, a new group is created.
|
||||
* @param title title of the group to look for
|
||||
* @return id of the group with given title
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
* @param title title of the group to look for
|
||||
* @return id of the group with given title
|
||||
* @throws RemoteException on content provider errors
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun findOrCreateGroup(title: String): Long {
|
||||
try {
|
||||
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
|
||||
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
|
||||
return ContentUris.parseId(uri)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't find local contact group", e)
|
||||
provider!!.query(syncAdapterURI(Groups.CONTENT_URI), arrayOf(Groups._ID),
|
||||
"${Groups.TITLE}=?", arrayOf(title), null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getLong(0)
|
||||
}
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.TITLE, title)
|
||||
val uri = provider.insert(syncAdapterURI(Groups.CONTENT_URI), values)
|
||||
return ContentUris.parseId(uri)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun removeEmptyGroups() {
|
||||
// find groups without members
|
||||
/** should be done using {@link Groups.SUMMARY_COUNT}, but it's not implemented in Android yet */
|
||||
@@ -308,52 +339,4 @@ class LocalAddressBook(
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun removeGroups() {
|
||||
try {
|
||||
provider!!.delete(syncAdapterURI(Groups.CONTENT_URI), null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't remove all groups", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SETTINGS
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getMainAccount(): Account {
|
||||
val accountManager = AccountManager.get(context)
|
||||
val name = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_NAME)
|
||||
val type = accountManager.getUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE)
|
||||
if (name != null && type != null)
|
||||
return Account(name, type)
|
||||
else
|
||||
throw ContactsStorageException("Address book doesn't exist anymore")
|
||||
}
|
||||
|
||||
fun setMainAccount(mainAccount: Account) {
|
||||
val accountManager = AccountManager.get(context)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_NAME, mainAccount.name)
|
||||
accountManager.setUserData(account, USER_DATA_MAIN_ACCOUNT_TYPE, mainAccount.type)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getURL() =
|
||||
AccountManager.get(context).getUserData(account, USER_DATA_URL) ?: throw ContactsStorageException("Address book has no URL")
|
||||
|
||||
fun setURL(url: String) =
|
||||
AccountManager.get(context).setUserData(account, USER_DATA_URL, url)
|
||||
|
||||
fun getReadOnly() =
|
||||
AccountManager.get(context).getUserData(account, USER_DATA_READ_ONLY) != null
|
||||
|
||||
fun setReadOnly(readOnly: Boolean) =
|
||||
AccountManager.get(context).setUserData(account, USER_DATA_READ_ONLY, if (readOnly) "1" else null)
|
||||
|
||||
override fun getCTag(): String? =
|
||||
AccountManager.get(context).getUserData(account, USER_DATA_CTAG)
|
||||
|
||||
override fun setCTag(cTag: String?) =
|
||||
AccountManager.get(context).setUserData(account, USER_DATA_CTAG, cTag)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,17 @@ import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.CalendarContract.*
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.ical4android.*
|
||||
import java.io.FileNotFoundException
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import at.bitfire.ical4android.AndroidCalendarFactory
|
||||
import at.bitfire.ical4android.BatchOperation
|
||||
import at.bitfire.ical4android.DateUtils
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
@@ -33,18 +36,8 @@ class LocalCalendar private constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
val defaultColor = 0xFF8bc34a.toInt() // light green 500
|
||||
private const val COLUMN_SYNC_STATE = Calendars.CAL_SYNC1
|
||||
|
||||
val COLUMN_CTAG = Calendars.CAL_SYNC1
|
||||
|
||||
val BASE_INFO_COLUMNS = arrayOf(
|
||||
Events._ID,
|
||||
Events._SYNC_ID,
|
||||
LocalEvent.COLUMN_ETAG
|
||||
)
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun create(account: Account, provider: ContentProviderClient, info: CollectionInfo): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
|
||||
@@ -56,18 +49,16 @@ class LocalCalendar private constructor(
|
||||
// flag as visible & synchronizable at creation, might be changed by user at any time
|
||||
values.put(Calendars.VISIBLE, 1)
|
||||
values.put(Calendars.SYNC_EVENTS, 1)
|
||||
val uri = create(account, provider, values)
|
||||
|
||||
return uri
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues()
|
||||
values.put(Calendars.NAME, info.url)
|
||||
values.put(Calendars.NAME, info.url.toString())
|
||||
values.put(Calendars.CALENDAR_DISPLAY_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: defaultColor)
|
||||
values.put(Calendars.CALENDAR_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
if (info.readOnly || info.forceReadOnly)
|
||||
values.put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_READ)
|
||||
@@ -92,31 +83,33 @@ class LocalCalendar private constructor(
|
||||
values.put(Calendars.ALLOWED_ATTENDEE_TYPES, "${CalendarContract.Attendees.TYPE_OPTIONAL},${CalendarContract.Attendees.TYPE_REQUIRED},${CalendarContract.Attendees.TYPE_RESOURCE}")
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override val title: String
|
||||
get() = displayName ?: id.toString()
|
||||
|
||||
override fun eventBaseInfoColumns() = BASE_INFO_COLUMNS
|
||||
override var lastSyncState: SyncState?
|
||||
get() = provider.query(calendarSyncURI(), arrayOf(COLUMN_SYNC_STATE), null, null, null)?.let { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return SyncState.fromString(cursor.getString(0))
|
||||
else
|
||||
null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_SYNC_STATE, state.toString())
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getAll(): List<LocalEvent> =
|
||||
queryEvents("${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getDeleted() =
|
||||
override fun findDeleted() =
|
||||
queryEvents("${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getWithoutFileName() =
|
||||
queryEvents("${Events._SYNC_ID} IS NULL AND ${Events.ORIGINAL_ID} IS NULL", null)
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun getDirty(): List<LocalEvent> {
|
||||
override fun findDirty(): List<LocalEvent> {
|
||||
val dirty = LinkedList<LocalEvent>()
|
||||
|
||||
// get dirty events which are required to have an increased SEQUENCE value
|
||||
@@ -133,50 +126,46 @@ class LocalCalendar private constructor(
|
||||
return dirty
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getCTag(): String? =
|
||||
try {
|
||||
provider.query(calendarSyncURI(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
null
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
|
||||
}
|
||||
override fun findByName(name: String) =
|
||||
queryEvents("${Events._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun setCTag(cTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_CTAG, cTag)
|
||||
provider.update(calendarSyncURI(), values, null, null)
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
|
||||
}
|
||||
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalEvent.COLUMN_FLAGS, flags)
|
||||
return provider.update(eventsSyncURI(), values,
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.delete(eventsSyncURI(),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}=0 AND ${Events.ORIGINAL_ID} IS NULL AND ${LocalEvent.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
|
||||
fun processDirtyExceptions() {
|
||||
try {
|
||||
// process deleted exceptions
|
||||
Logger.log.info("Processing deleted exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found deleted exception, removing; then re-scheduling original event")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
// process deleted exceptions
|
||||
Logger.log.info("Processing deleted exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DELETED}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(id.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found deleted exception, removing and re-scheduling original event (if available)")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
val batch = BatchOperation(provider)
|
||||
|
||||
// get original event's SEQUENCE
|
||||
provider.query(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
arrayOf(LocalEvent.COLUMN_SEQUENCE),
|
||||
null, null, null)?.use { cursor2 ->
|
||||
// get original event's SEQUENCE
|
||||
provider.query(
|
||||
syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)),
|
||||
arrayOf(LocalEvent.COLUMN_SEQUENCE),
|
||||
null, null, null)?.use { cursor2 ->
|
||||
if (cursor2.moveToNext()) {
|
||||
// original event is available
|
||||
val originalSequence = if (cursor2.isNull(0)) 0 else cursor2.getInt(0)
|
||||
|
||||
// re-schedule original event and set it to DIRTY
|
||||
@@ -186,44 +175,43 @@ class LocalCalendar private constructor(
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
}
|
||||
|
||||
// remove exception
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Logger.log.info("Processing dirty exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL", null, null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
// original event to DIRTY
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
// completely remove deleted exception
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newDelete(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// process dirty exceptions
|
||||
Logger.log.info("Processing dirty exceptions")
|
||||
provider.query(
|
||||
syncAdapterURI(Events.CONTENT_URI),
|
||||
arrayOf(Events._ID, Events.ORIGINAL_ID, LocalEvent.COLUMN_SEQUENCE),
|
||||
"${Events.CALENDAR_ID}=? AND ${Events.DIRTY}!=0 AND ${Events.ORIGINAL_ID} IS NOT NULL",
|
||||
arrayOf(id.toString()), null)?.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
Logger.log.fine("Found dirty exception, increasing SEQUENCE to re-schedule")
|
||||
val id = cursor.getLong(0) // can't be null (by definition)
|
||||
val originalID = cursor.getLong(1) // can't be null (by query)
|
||||
val sequence = if (cursor.isNull(2)) 0 else cursor.getInt(2)
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
// original event to DIRTY
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, originalID)))
|
||||
.withValue(Events.DIRTY, 1)
|
||||
))
|
||||
// increase SEQUENCE and set DIRTY to 0
|
||||
batch.enqueue(BatchOperation.Operation (
|
||||
ContentProviderOperation.newUpdate(syncAdapterURI(ContentUris.withAppendedId(Events.CONTENT_URI, id)))
|
||||
.withValue(LocalEvent.COLUMN_SEQUENCE, sequence + 1)
|
||||
.withValue(Events.DIRTY, 0)
|
||||
))
|
||||
batch.commit()
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
throw CalendarStorageException("Couldn't process locally modified exception", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,4 +223,4 @@ class LocalCalendar private constructor(
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,28 +8,31 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import java.io.FileNotFoundException
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
|
||||
interface LocalCollection<out T: LocalResource> {
|
||||
interface LocalCollection<out T: LocalResource<*>> {
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getDeleted(): List<T>
|
||||
/** collection title (used for user notifications etc.) **/
|
||||
val title: String
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getWithoutFileName(): List<T>
|
||||
var lastSyncState: SyncState?
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class, FileNotFoundException::class)
|
||||
fun getDirty(): List<T>
|
||||
fun findDeleted(): List<T>
|
||||
fun findDirty(): List<T>
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getAll(): List<T>
|
||||
fun findByName(name: String): T?
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun getCTag(): String?
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun setCTag(cTag: String?)
|
||||
/**
|
||||
* Marks all entries which are not dirty with the given flags only.
|
||||
* @return number of marked entries
|
||||
**/
|
||||
fun markNotDirty(flags: Int): Int
|
||||
|
||||
/**
|
||||
* Removes all entries with are not dirty and are marked with exactly the given flags.
|
||||
* @return number of removed entries
|
||||
*/
|
||||
fun removeNotDirtyMarked(flags: Int): Int
|
||||
|
||||
}
|
||||
@@ -23,129 +23,110 @@ import ezvcard.Ezvcard
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalContact: AndroidContact, LocalResource {
|
||||
class LocalContact: AndroidContact, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
init {
|
||||
Contact.productID = "+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ez-vcard/" + Ezvcard.VERSION
|
||||
}
|
||||
|
||||
val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
|
||||
const val COLUMN_FLAGS = ContactsContract.RawContacts.SYNC4
|
||||
const val COLUMN_HASHCODE = ContactsContract.RawContacts.SYNC3
|
||||
}
|
||||
|
||||
private val cachedGroupMemberships = HashSet<Long>()
|
||||
private val groupMemberships = HashSet<Long>()
|
||||
|
||||
override var flags: Int = 0
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?):
|
||||
super(addressBook, contact, fileName, eTag)
|
||||
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.RawContacts.DELETED, 0)
|
||||
try {
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't clear deleted flag", e)
|
||||
}
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<LocalContact,*>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val values = ContentValues(3)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val hashCode = dataHashCode()
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
|
||||
}
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun resetDirty() {
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
try {
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't clear dirty flag", e)
|
||||
}
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(3)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(ContactsContract.RawContacts.DIRTY, 0)
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalContact.COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val hashCode = dataHashCode()
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
Logger.log.finer("Clearing dirty flag with eTag = $eTag, contact hash = $hashCode")
|
||||
}
|
||||
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't clear dirty flag", e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = uid + ".vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't update UID", e)
|
||||
}
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun populateData(mimeType: String, row: ContentValues) {
|
||||
when (mimeType) {
|
||||
CachedGroupMembership.CONTENT_ITEM_TYPE ->
|
||||
cachedGroupMemberships.add(row.getAsLong(CachedGroupMembership.GROUP_ID))
|
||||
cachedGroupMemberships += row.getAsLong(CachedGroupMembership.GROUP_ID)
|
||||
GroupMembership.CONTENT_ITEM_TYPE ->
|
||||
groupMemberships.add(row.getAsLong(GroupMembership.GROUP_ROW_ID))
|
||||
groupMemberships += row.getAsLong(GroupMembership.GROUP_ROW_ID)
|
||||
UnknownProperties.CONTENT_ITEM_TYPE ->
|
||||
try {
|
||||
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't fetch data rows", e)
|
||||
}
|
||||
contact!!.unknownProperties = row.getAsString(UnknownProperties.UNKNOWN_PROPERTIES)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun insertDataRows(batch: BatchOperation) {
|
||||
super.insertDataRows(batch)
|
||||
|
||||
try {
|
||||
contact!!.unknownProperties?.let { unknownProperties ->
|
||||
val op: BatchOperation.Operation
|
||||
val builder = ContentProviderOperation.newInsert(dataSyncURI())
|
||||
if (id == null)
|
||||
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
|
||||
else {
|
||||
op = BatchOperation.Operation(builder)
|
||||
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
|
||||
}
|
||||
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
batch.enqueue(op)
|
||||
contact!!.unknownProperties?.let { unknownProperties ->
|
||||
val op: BatchOperation.Operation
|
||||
val builder = ContentProviderOperation.newInsert(dataSyncURI())
|
||||
if (id == null)
|
||||
op = BatchOperation.Operation(builder, UnknownProperties.RAW_CONTACT_ID, 0)
|
||||
else {
|
||||
op = BatchOperation.Operation(builder)
|
||||
builder.withValue(UnknownProperties.RAW_CONTACT_ID, id)
|
||||
}
|
||||
} catch(e: FileNotFoundException) {
|
||||
throw ContactsStorageException("Couldn't insert data rows", e)
|
||||
builder .withValue(UnknownProperties.MIMETYPE, UnknownProperties.CONTENT_ITEM_TYPE)
|
||||
.withValue(UnknownProperties.UNKNOWN_PROPERTIES, unknownProperties)
|
||||
batch.enqueue(op)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +135,6 @@ class LocalContact: AndroidContact, LocalResource {
|
||||
* Attention: re-reads {@link #contact} from the database, discarding all changes in memory
|
||||
* @return hash code of contact data (including group memberships)
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
internal fun dataHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("dataHashCode() should not be called on Android != 7")
|
||||
@@ -169,44 +149,34 @@ class LocalContact: AndroidContact, LocalResource {
|
||||
return dataHash xor groupHash
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun updateHashCode(batch: BatchOperation?) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("updateHashCode() should not be called on Android != 7")
|
||||
|
||||
val values = ContentValues(1)
|
||||
try {
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
val hashCode = dataHashCode()
|
||||
Logger.log.fine("Storing contact hash = $hashCode")
|
||||
values.put(COLUMN_HASHCODE, hashCode)
|
||||
|
||||
if (batch == null)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
else {
|
||||
val builder = ContentProviderOperation
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValues(values)
|
||||
batch.enqueue(BatchOperation.Operation(builder))
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
throw ContactsStorageException("Couldn't store contact checksum", e)
|
||||
if (batch == null)
|
||||
addressBook.provider!!.update(rawContactSyncURI(), values, null, null)
|
||||
else {
|
||||
val builder = ContentProviderOperation
|
||||
.newUpdate(rawContactSyncURI())
|
||||
.withValues(values)
|
||||
batch.enqueue(BatchOperation.Operation(builder))
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun getLastHashCode(): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
throw IllegalStateException("getLastHashCode() should not be called on Android != 7")
|
||||
|
||||
try {
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Could't read last hash code", e)
|
||||
addressBook.provider!!.query(rawContactSyncURI(), arrayOf(COLUMN_HASHCODE), null, null, null)?.use { c ->
|
||||
if (c.moveToNext() && !c.isNull(0))
|
||||
return c.getInt(0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +187,7 @@ class LocalContact: AndroidContact, LocalResource {
|
||||
.withValue(GroupMembership.RAW_CONTACT_ID, id)
|
||||
.withValue(GroupMembership.GROUP_ROW_ID, groupID)
|
||||
))
|
||||
groupMemberships.add(groupID)
|
||||
groupMemberships += groupID
|
||||
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newInsert(dataSyncURI())
|
||||
@@ -226,7 +196,7 @@ class LocalContact: AndroidContact, LocalResource {
|
||||
.withValue(CachedGroupMembership.GROUP_ID, groupID)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
cachedGroupMemberships.add(groupID)
|
||||
cachedGroupMemberships += groupID
|
||||
}
|
||||
|
||||
fun removeGroupMemberships(batch: BatchOperation) {
|
||||
@@ -247,10 +217,9 @@ class LocalContact: AndroidContact, LocalResource {
|
||||
* Cached memberships are kept in sync with memberships by DAVdroid and are used to determine
|
||||
* whether a membership has been deleted/added when a raw contact is dirty.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID} (may be empty)
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
fun getCachedGroupMemberships(): Set<Long> {
|
||||
contact
|
||||
return cachedGroupMemberships
|
||||
@@ -259,26 +228,26 @@ class LocalContact: AndroidContact, LocalResource {
|
||||
/**
|
||||
* Returns the IDs of all groups the contact is member of.
|
||||
* @return set of {@link GroupMembership#GROUP_ROW_ID}s (may be empty)
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws FileNotFoundException if the current contact can't be found
|
||||
* @throws RemoteException on contacts provider errors
|
||||
*/
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
fun getGroupMemberships(): Set<Long> {
|
||||
contact
|
||||
return groupMemberships
|
||||
}
|
||||
|
||||
|
||||
// data rows
|
||||
override fun buildContact(builder: ContentProviderOperation.Builder, update: Boolean) {
|
||||
builder.withValue(COLUMN_FLAGS, flags)
|
||||
super.buildContact(builder, update)
|
||||
}
|
||||
|
||||
// factory
|
||||
|
||||
object Factory: AndroidContactFactory<LocalContact> {
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, id: Long, fileName: String?, eTag: String?) =
|
||||
LocalContact(addressBook, id, fileName, eTag)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<LocalContact, *>, contact: Contact, fileName: String?, eTag: String?) =
|
||||
LocalContact(addressBook, contact, fileName, eTag)
|
||||
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<LocalContact, *>, values: ContentValues) =
|
||||
LocalContact(addressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,56 +15,54 @@ import android.provider.CalendarContract.Events
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.ical4android.*
|
||||
import net.fortuna.ical4j.model.property.ProdId
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
|
||||
class LocalEvent: AndroidEvent, LocalResource {
|
||||
class LocalEvent: AndroidEvent, LocalResource<Event> {
|
||||
|
||||
companion object {
|
||||
init {
|
||||
iCalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/2.x")
|
||||
ICalendar.prodId = ProdId("+//IDN bitfire.at//DAVdroid/" + BuildConfig.VERSION_NAME + " ical4j/" + Constants.ical4jVersion)
|
||||
}
|
||||
|
||||
val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
|
||||
val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
|
||||
const val COLUMN_ETAG = CalendarContract.Events.SYNC_DATA1
|
||||
const val COLUMN_FLAGS = CalendarContract.Events.SYNC_DATA2
|
||||
const val COLUMN_SEQUENCE = CalendarContract.Events.SYNC_DATA3
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
private set
|
||||
|
||||
override var eTag: String? = null
|
||||
|
||||
override var flags: Int = 0
|
||||
private set
|
||||
|
||||
var weAreOrganizer = true
|
||||
|
||||
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?): super(calendar, event) {
|
||||
constructor(calendar: AndroidCalendar<*>, event: Event, fileName: String?, eTag: String?, flags: Int): super(calendar, event) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?): super(calendar, id, baseInfo) {
|
||||
baseInfo?.let {
|
||||
fileName = it.getAsString(Events._SYNC_ID)
|
||||
eTag = it.getAsString(COLUMN_ETAG)
|
||||
}
|
||||
private constructor(calendar: AndroidCalendar<*>, values: ContentValues): super(calendar, values) {
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
|
||||
/* process LocalEvent-specific fields */
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun populateEvent(row: ContentValues) {
|
||||
super.populateEvent(row)
|
||||
val event = requireNotNull(event)
|
||||
|
||||
fileName = row.getAsString(Events._SYNC_ID)
|
||||
eTag = row.getAsString(COLUMN_ETAG)
|
||||
event.uid = row.getAsString(Events.UID_2445)
|
||||
|
||||
event.sequence = row.getAsInteger(COLUMN_SEQUENCE)
|
||||
|
||||
val isOrganizer = row.getAsInteger(Events.IS_ORGANIZER)
|
||||
weAreOrganizer = isOrganizer != null && isOrganizer != 0
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun buildEvent(recurrence: Event?, builder: ContentProviderOperation.Builder) {
|
||||
super.buildEvent(recurrence, builder)
|
||||
val event = requireNotNull(event)
|
||||
@@ -76,6 +74,7 @@ class LocalEvent: AndroidEvent, LocalResource {
|
||||
.withValue(COLUMN_SEQUENCE, eventToBuild.sequence)
|
||||
.withValue(CalendarContract.Events.DIRTY, 0)
|
||||
.withValue(CalendarContract.Events.DELETED, 0)
|
||||
.withValue(LocalEvent.COLUMN_FLAGS, flags)
|
||||
|
||||
if (buildException)
|
||||
builder .withValue(Events.ORIGINAL_SYNC_ID, fileName)
|
||||
@@ -85,57 +84,48 @@ class LocalEvent: AndroidEvent, LocalResource {
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
if (uid == null)
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Events._SYNC_ID, newFileName)
|
||||
values.put(Events.UID_2445, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
event!!.uid = uid
|
||||
|
||||
} catch(e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
override fun assignNameAndUID() {
|
||||
var uid: String? = null
|
||||
calendar.provider.query(eventSyncURI(), arrayOf(Events.UID_2445), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
uid = cursor.getString(0)
|
||||
}
|
||||
if (uid == null)
|
||||
uid = UUID.randomUUID().toString()
|
||||
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Events._SYNC_ID, newFileName)
|
||||
values.put(Events.UID_2445, uid)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
event!!.uid = uid
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(2)
|
||||
values.put(CalendarContract.Events.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
val values = ContentValues(2)
|
||||
values.put(CalendarContract.Events.DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(COLUMN_SEQUENCE, event!!.sequence)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
calendar.provider.update(eventSyncURI(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidEventFactory<LocalEvent> {
|
||||
|
||||
override fun newInstance(calendar: AndroidCalendar<*>, id: Long, baseInfo: ContentValues?) =
|
||||
LocalEvent(calendar, id, baseInfo)
|
||||
|
||||
override fun newInstance(calendar: AndroidCalendar<*>, event: Event) =
|
||||
LocalEvent(calendar, event, null, null)
|
||||
|
||||
override fun fromProvider(calendar: AndroidCalendar<*>, values: ContentValues) =
|
||||
LocalEvent(calendar, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,6 +11,7 @@ package at.bitfire.davdroid.resource
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
@@ -21,116 +22,126 @@ import android.provider.ContactsContract.RawContacts
|
||||
import android.provider.ContactsContract.RawContacts.Data
|
||||
import at.bitfire.dav4android.Constants
|
||||
import at.bitfire.vcard4android.*
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalGroup: AndroidGroup, LocalResource {
|
||||
class LocalGroup: AndroidGroup, LocalAddress {
|
||||
|
||||
companion object {
|
||||
|
||||
const val COLUMN_FLAGS = Groups.SYNC4
|
||||
|
||||
/** marshaled list of member UIDs, as sent by server */
|
||||
val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
const val COLUMN_PENDING_MEMBERS = Groups.SYNC3
|
||||
|
||||
/**
|
||||
* Processes all groups with non-null {@link #COLUMN_PENDING_MEMBERS}: the pending memberships
|
||||
* are (if possible) applied, keeping cached memberships in sync.
|
||||
* @param addressBook address book to take groups from
|
||||
* @throws ContactsStorageException on contact provider errors
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun applyPendingMemberships(addressBook: LocalAddressBook) {
|
||||
try {
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(Groups.CONTENT_URI),
|
||||
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
|
||||
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val batch = BatchOperation(addressBook.provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
Constants.log.fine("Assigning members to group $id")
|
||||
addressBook.provider!!.query(
|
||||
addressBook.groupsSyncUri(),
|
||||
arrayOf(Groups._ID, COLUMN_PENDING_MEMBERS),
|
||||
"$COLUMN_PENDING_MEMBERS IS NOT NULL", null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val batch = BatchOperation(addressBook.provider)
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.getLong(0)
|
||||
Constants.log.fine("Assigning members to group $id")
|
||||
|
||||
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val changeContactIDs = HashSet<Long>()
|
||||
// required for workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val changeContactIDs = HashSet<Long>()
|
||||
|
||||
// delete all memberships and cached memberships for this group
|
||||
for (contact in addressBook.getByGroupMembership(id)) {
|
||||
contact.removeGroupMemberships(batch)
|
||||
changeContactIDs.add(contact.id!!)
|
||||
}
|
||||
|
||||
// extract list of member UIDs
|
||||
val members = LinkedList<String>()
|
||||
val raw = cursor.getBlob(1)
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
parcel.readStringList(members)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
|
||||
// insert memberships
|
||||
for (uid in members) {
|
||||
Constants.log.fine("Assigning member: $uid")
|
||||
try {
|
||||
val member = addressBook.findContactByUID(uid)
|
||||
member.addToGroup(batch, id)
|
||||
changeContactIDs.add(member.id!!)
|
||||
} catch(e: FileNotFoundException) {
|
||||
Constants.log.log(Level.WARNING, "Group member not found: $uid", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { LocalContact(addressBook, it, null, null) }
|
||||
.forEach { it.updateHashCode(batch) }
|
||||
|
||||
// remove pending memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
|
||||
.withValue(COLUMN_PENDING_MEMBERS, null)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
// delete all memberships and cached memberships for this group
|
||||
for (contact in addressBook.getByGroupMembership(id)) {
|
||||
contact.removeGroupMemberships(batch)
|
||||
changeContactIDs += contact.id!!
|
||||
}
|
||||
|
||||
// extract list of member UIDs
|
||||
val members = LinkedList<String>()
|
||||
val raw = cursor.getBlob(1)
|
||||
val parcel = Parcel.obtain()
|
||||
try {
|
||||
parcel.unmarshall(raw, 0, raw.size)
|
||||
parcel.setDataPosition(0)
|
||||
parcel.readStringList(members)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
|
||||
// insert memberships
|
||||
for (uid in members) {
|
||||
Constants.log.fine("Assigning member: $uid")
|
||||
addressBook.findContactByUID(uid)?.let { member ->
|
||||
member.addToGroup(batch, id)
|
||||
changeContactIDs += member.id!!
|
||||
} ?: Constants.log.warning("Group member not found: $uid")
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
changeContactIDs
|
||||
.map { addressBook.findContactByID(it) }
|
||||
.forEach { it.updateHashCode(batch) }
|
||||
|
||||
// remove pending memberships
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(addressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, id)))
|
||||
.withValue(COLUMN_PENDING_MEMBERS, null)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't get pending memberships", e)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?):
|
||||
super(addressBook, id, fileName, eTag)
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?):
|
||||
super(addressBook, contact, fileName, eTag)
|
||||
override var flags: Int = 0
|
||||
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun resetDeleted() {
|
||||
val uri = ContentUris.withAppendedId(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), requireNotNull(id))
|
||||
|
||||
val values = ContentValues(1)
|
||||
values.put(ContactsContract.Groups.DELETED, 0)
|
||||
try {
|
||||
addressBook.provider!!.update(uri, values, null, null)
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't clear deleted flag", e)
|
||||
}
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues)
|
||||
: super(addressBook, values) {
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
constructor(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(addressBook, contact, fileName, eTag) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
members.writeStringList(contact!!.members)
|
||||
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
|
||||
} finally {
|
||||
members.recycle()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
update(values)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
val id = requireNotNull(id)
|
||||
|
||||
@@ -165,38 +176,9 @@ class LocalGroup: AndroidGroup, LocalResource {
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
@Throws(ContactsStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.vcf"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(COLUMN_FILENAME, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
update(values)
|
||||
|
||||
fileName = newFileName
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, ContactsStorageException::class)
|
||||
override fun contentValues(): ContentValues {
|
||||
val values = super.contentValues()
|
||||
|
||||
val members = Parcel.obtain()
|
||||
try {
|
||||
members.writeStringList(contact!!.members)
|
||||
values.put(COLUMN_PENDING_MEMBERS, members.marshall())
|
||||
} finally {
|
||||
members.recycle()
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Marks all members of the current group as dirty.
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
fun markMembersDirty() {
|
||||
val batch = BatchOperation(addressBook.provider!!)
|
||||
|
||||
@@ -210,31 +192,45 @@ class LocalGroup: AndroidGroup, LocalResource {
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
override fun resetDeleted() {
|
||||
val values = ContentValues(1)
|
||||
values.put(Groups.DELETED, 0)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalGroup.COLUMN_FLAGS, flags)
|
||||
addressBook.provider!!.update(groupSyncUri(), values, null, null)
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun groupSyncUri(): Uri {
|
||||
val id = requireNotNull(id)
|
||||
return ContentUris.withAppendedId(addressBook.groupsSyncUri(), id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of this group.
|
||||
* @return list of all members' raw contact IDs
|
||||
* @throws ContactsStorageException on contact provider errorst
|
||||
* @throws RemoteException on contact provider errors
|
||||
*/
|
||||
@Throws(ContactsStorageException::class)
|
||||
internal fun getMembers(): List<Long> {
|
||||
val id = requireNotNull(id)
|
||||
val members = LinkedList<Long>()
|
||||
try {
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(Data.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
members.add(cursor.getLong(0))
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't list group members", e)
|
||||
addressBook.provider!!.query(
|
||||
addressBook.syncAdapterURI(ContactsContract.Data.CONTENT_URI),
|
||||
arrayOf(Data.RAW_CONTACT_ID),
|
||||
"${GroupMembership.MIMETYPE}=? AND ${GroupMembership.GROUP_ROW_ID}=?",
|
||||
arrayOf(GroupMembership.CONTENT_ITEM_TYPE, id.toString()),
|
||||
null
|
||||
)?.use { cursor ->
|
||||
while (cursor.moveToNext())
|
||||
members += cursor.getLong(0)
|
||||
}
|
||||
return members
|
||||
}
|
||||
@@ -243,13 +239,8 @@ class LocalGroup: AndroidGroup, LocalResource {
|
||||
// factory
|
||||
|
||||
object Factory: AndroidGroupFactory<LocalGroup> {
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, id: Long, fileName: String?, eTag: String?) =
|
||||
LocalGroup(addressBook, id, fileName, eTag)
|
||||
|
||||
override fun newInstance(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, contact: Contact, fileName: String?, eTag: String?) =
|
||||
LocalGroup(addressBook, contact, fileName, eTag)
|
||||
|
||||
override fun fromProvider(addressBook: AndroidAddressBook<out AndroidContact, LocalGroup>, values: ContentValues) =
|
||||
LocalGroup(addressBook, values)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,23 +8,50 @@
|
||||
|
||||
package at.bitfire.davdroid.resource
|
||||
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import android.net.Uri
|
||||
|
||||
interface LocalResource {
|
||||
interface LocalResource<in TData: Any> {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Resource is present on remote server. This flag is used to identify resources
|
||||
* which are not present on the remote server anymore and can be deleted at the end
|
||||
* of the synchronization.
|
||||
*/
|
||||
const val FLAG_REMOTELY_PRESENT = 1
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Unique ID which identifies the resource in the local storage. May be null if the
|
||||
* resource has not been saved yet.
|
||||
*/
|
||||
val id: Long?
|
||||
|
||||
var fileName: String?
|
||||
val fileName: String?
|
||||
var eTag: String?
|
||||
val flags: Int
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun assignNameAndUID()
|
||||
fun clearDirty(eTag: String?)
|
||||
fun updateFlags(flags: Int)
|
||||
|
||||
/**
|
||||
* Adds the data object to the content provider and ensures that the dirty flag is clear.
|
||||
* @return content URI of the created row (e.g. event URI)
|
||||
*/
|
||||
fun add(): Uri
|
||||
|
||||
/**
|
||||
* Updates the data object in the content provider and ensures that the dirty flag is clear.
|
||||
* @return content URI of the updated row (e.g. event URI)
|
||||
*/
|
||||
fun update(data: TData): Uri
|
||||
|
||||
/**
|
||||
* Deletes the data object from the content provider.
|
||||
* @return number of affected rows
|
||||
*/
|
||||
fun delete(): Int
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun prepareForUpload()
|
||||
|
||||
@Throws(CalendarStorageException::class, ContactsStorageException::class)
|
||||
fun clearDirty(eTag: String?)
|
||||
|
||||
}
|
||||
@@ -10,108 +10,93 @@ package at.bitfire.davdroid.resource
|
||||
|
||||
import android.content.ContentProviderOperation
|
||||
import android.content.ContentValues
|
||||
import android.provider.CalendarContract.Events
|
||||
import at.bitfire.ical4android.*
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks
|
||||
import java.io.FileNotFoundException
|
||||
import java.text.ParseException
|
||||
import at.bitfire.ical4android.AndroidTask
|
||||
import at.bitfire.ical4android.AndroidTaskFactory
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.Task
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.*
|
||||
|
||||
class LocalTask: AndroidTask, LocalResource {
|
||||
class LocalTask: AndroidTask, LocalResource<Task> {
|
||||
|
||||
companion object {
|
||||
val COLUMN_ETAG = Tasks.SYNC1
|
||||
val COLUMN_UID = Tasks.SYNC2
|
||||
val COLUMN_SEQUENCE = Tasks.SYNC3
|
||||
const val COLUMN_ETAG = Tasks.SYNC1
|
||||
const val COLUMN_FLAGS = Tasks.SYNC2
|
||||
}
|
||||
|
||||
override var fileName: String? = null
|
||||
override var eTag: String? = null
|
||||
|
||||
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?): super(taskList, task) {
|
||||
override var flags = 0
|
||||
private set
|
||||
|
||||
|
||||
constructor(taskList: AndroidTaskList<*>, task: Task, fileName: String?, eTag: String?, flags: Int)
|
||||
: super(taskList, task) {
|
||||
this.fileName = fileName
|
||||
this.eTag = eTag
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
private constructor(taskList: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?): super(taskList, id) {
|
||||
baseInfo?.let {
|
||||
fileName = it.getAsString(Events._SYNC_ID)
|
||||
eTag = it.getAsString(COLUMN_ETAG)
|
||||
}
|
||||
private constructor(taskList: AndroidTaskList<*>, values: ContentValues): super(taskList) {
|
||||
id = values.getAsLong(Tasks._ID)
|
||||
fileName = values.getAsString(Tasks._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
flags = values.getAsInteger(COLUMN_FLAGS) ?: 0
|
||||
}
|
||||
|
||||
|
||||
/* process LocalTask-specific fields */
|
||||
|
||||
@Throws(ParseException::class)
|
||||
override fun populateTask(values: ContentValues) {
|
||||
super.populateTask(values)
|
||||
|
||||
fileName = values.getAsString(Events._SYNC_ID)
|
||||
eTag = values.getAsString(COLUMN_ETAG)
|
||||
|
||||
val task = requireNotNull(task)
|
||||
task.uid = values.getAsString(COLUMN_UID)
|
||||
task.sequence = values.getAsInteger(COLUMN_SEQUENCE)
|
||||
}
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun buildTask(builder: ContentProviderOperation.Builder, update: Boolean) {
|
||||
super.buildTask(builder, update)
|
||||
val task = requireNotNull(task)
|
||||
|
||||
builder .withValue(Tasks._SYNC_ID, fileName)
|
||||
.withValue(COLUMN_UID, task.uid)
|
||||
.withValue(COLUMN_SEQUENCE, task.sequence)
|
||||
.withValue(COLUMN_ETAG, eTag)
|
||||
.withValue(COLUMN_FLAGS, flags)
|
||||
}
|
||||
|
||||
|
||||
/* custom queries */
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun prepareForUpload() {
|
||||
try {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = uid + ".ics"
|
||||
override fun assignNameAndUID() {
|
||||
val uid = UUID.randomUUID().toString()
|
||||
val newFileName = "$uid.ics"
|
||||
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(COLUMN_UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._SYNC_ID, newFileName)
|
||||
values.put(Tasks._UID, uid)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
fileName = newFileName
|
||||
fileName = newFileName
|
||||
|
||||
task!!.uid = uid
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update UID", e)
|
||||
}
|
||||
task!!.uid = uid
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun clearDirty(eTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(2)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
val values = ContentValues(3)
|
||||
values.put(Tasks._DIRTY, 0)
|
||||
values.put(COLUMN_ETAG, eTag)
|
||||
values.put(Tasks.SYNC_VERSION, task!!.sequence)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
values.put(COLUMN_SEQUENCE, task!!.sequence)
|
||||
this.eTag = eTag
|
||||
}
|
||||
|
||||
override fun updateFlags(flags: Int) {
|
||||
if (id != null) {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_FLAGS, flags)
|
||||
taskList.provider.client.update(taskSyncURI(), values, null, null)
|
||||
|
||||
this.eTag = eTag
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't update _DIRTY/ETag/SEQUENCE", e)
|
||||
}
|
||||
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
|
||||
object Factory: AndroidTaskFactory<LocalTask> {
|
||||
|
||||
override fun newInstance(calendar: AndroidTaskList<*>, id: Long, baseInfo: ContentValues?) =
|
||||
LocalTask(calendar, id, baseInfo)
|
||||
|
||||
override fun newInstance(calendar: AndroidTaskList<*>, task: Task) =
|
||||
LocalTask(calendar, task, null, null)
|
||||
|
||||
override fun fromProvider(taskList: AndroidTaskList<*>, values: ContentValues) =
|
||||
LocalTask(taskList, values)
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,17 @@ import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.AndroidTaskListFactory
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.provider.tasks.TaskContract.TaskLists
|
||||
import org.dmfs.provider.tasks.TaskContract.Tasks
|
||||
import java.io.FileNotFoundException
|
||||
import org.dmfs.tasks.contract.TaskContract.TaskLists
|
||||
import org.dmfs.tasks.contract.TaskContract.Tasks
|
||||
import java.util.logging.Level
|
||||
|
||||
class LocalTaskList private constructor(
|
||||
account: Account,
|
||||
@@ -33,29 +35,16 @@ class LocalTaskList private constructor(
|
||||
|
||||
companion object {
|
||||
|
||||
val defaultColor = 0xFFC3EA6E.toInt() // "DAVdroid green"
|
||||
|
||||
val COLUMN_CTAG = TaskLists.SYNC_VERSION
|
||||
|
||||
val BASE_INFO_COLUMNS = arrayOf(
|
||||
Tasks._ID,
|
||||
Tasks._SYNC_ID,
|
||||
LocalTask.COLUMN_ETAG
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun tasksProviderAvailable(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
return context.packageManager.resolveContentProvider(TaskProvider.ProviderName.OpenTasks.authority, 0) != null
|
||||
else {
|
||||
val provider = TaskProvider.acquire(context.contentResolver, TaskProvider.ProviderName.OpenTasks)
|
||||
val provider = TaskProvider.acquire(context, TaskProvider.ProviderName.OpenTasks)
|
||||
provider?.use { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun create(account: Account, provider: TaskProvider, info: CollectionInfo): Uri {
|
||||
val values = valuesFromCollectionInfo(info, true)
|
||||
values.put(TaskLists.OWNER, account.name)
|
||||
@@ -64,7 +53,6 @@ class LocalTaskList private constructor(
|
||||
return create(account, provider, values)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun onRenameAccount(resolver: ContentResolver, oldName: String, newName: String) {
|
||||
var client: ContentProviderClient? = null
|
||||
@@ -85,38 +73,49 @@ class LocalTaskList private constructor(
|
||||
|
||||
private fun valuesFromCollectionInfo(info: CollectionInfo, withColor: Boolean): ContentValues {
|
||||
val values = ContentValues(3)
|
||||
values.put(TaskLists._SYNC_ID, info.url)
|
||||
values.put(TaskLists._SYNC_ID, info.url.toString())
|
||||
values.put(TaskLists.LIST_NAME, if (info.displayName.isNullOrBlank()) DavUtils.lastSegmentOfUrl(info.url) else info.displayName)
|
||||
|
||||
if (withColor)
|
||||
values.put(TaskLists.LIST_COLOR, info.color ?: defaultColor)
|
||||
values.put(TaskLists.LIST_COLOR, info.color ?: Constants.DAVDROID_GREEN_RGBA)
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override val title: String
|
||||
get() = name ?: id.toString()
|
||||
|
||||
override fun taskBaseInfoColumns() = BASE_INFO_COLUMNS
|
||||
override var lastSyncState: SyncState?
|
||||
get() {
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(TaskLists.SYNC_VERSION),
|
||||
null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
cursor.getString(0)?.let {
|
||||
return SyncState.fromString(it)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't read sync state", e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
set(state) {
|
||||
val values = ContentValues(1)
|
||||
values.put(TaskLists.SYNC_VERSION, state?.toString())
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
}
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) {
|
||||
update(valuesFromCollectionInfo(info, updateColor));
|
||||
}
|
||||
fun update(info: CollectionInfo, updateColor: Boolean) =
|
||||
update(valuesFromCollectionInfo(info, updateColor))
|
||||
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getAll() = queryTasks(null, null)
|
||||
override fun findDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getDeleted() = queryTasks("${Tasks._DELETED}!=0", null)
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getWithoutFileName() = queryTasks("${Tasks._SYNC_ID} IS NULL", null)
|
||||
|
||||
@Throws(FileNotFoundException::class, CalendarStorageException::class)
|
||||
override fun getDirty(): List<LocalTask> {
|
||||
override fun findDirty(): List<LocalTask> {
|
||||
val tasks = queryTasks("${Tasks._DIRTY}!=0", null)
|
||||
for (localTask in tasks) {
|
||||
val task = requireNotNull(localTask.task)
|
||||
@@ -129,30 +128,23 @@ class LocalTaskList private constructor(
|
||||
return tasks
|
||||
}
|
||||
|
||||
override fun findByName(name: String) =
|
||||
queryTasks("${Tasks._SYNC_ID}=?", arrayOf(name)).firstOrNull()
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun getCTag(): String? =
|
||||
try {
|
||||
provider.client.query(taskListSyncUri(), arrayOf(COLUMN_CTAG), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToNext())
|
||||
return cursor.getString(0)
|
||||
}
|
||||
null
|
||||
} catch(e: Exception) {
|
||||
throw CalendarStorageException("Couldn't read local (last known) CTag", e)
|
||||
}
|
||||
|
||||
@Throws(CalendarStorageException::class)
|
||||
override fun setCTag(cTag: String?) {
|
||||
try {
|
||||
val values = ContentValues(1)
|
||||
values.put(COLUMN_CTAG, cTag)
|
||||
provider.client.update(taskListSyncUri(), values, null, null)
|
||||
} catch (e: Exception) {
|
||||
throw CalendarStorageException("Couldn't write local (last known) CTag", e)
|
||||
}
|
||||
override fun markNotDirty(flags: Int): Int {
|
||||
val values = ContentValues(1)
|
||||
values.put(LocalTask.COLUMN_FLAGS, flags)
|
||||
return provider.client.update(tasksSyncUri(), values,
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0",
|
||||
arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
override fun removeNotDirtyMarked(flags: Int) =
|
||||
provider.client.delete(tasksSyncUri(),
|
||||
"${Tasks.LIST_ID}=? AND ${Tasks._DIRTY}=0 AND ${LocalTask.COLUMN_FLAGS}=?",
|
||||
arrayOf(id.toString(), flags.toString()))
|
||||
|
||||
|
||||
object Factory: AndroidTaskListFactory<LocalTaskList> {
|
||||
|
||||
@@ -161,4 +153,4 @@ class LocalTaskList private constructor(
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,18 @@ open class DefaultsProvider(
|
||||
private val allowOverride: Boolean = true
|
||||
): Provider {
|
||||
|
||||
open val booleanDefaults = mapOf<String, Boolean>(
|
||||
open val booleanDefaults = mapOf(
|
||||
Pair(App.DISTRUST_SYSTEM_CERTIFICATES, false),
|
||||
Pair(App.OVERRIDE_PROXY, false)
|
||||
)
|
||||
|
||||
open val intDefaults = mapOf<String, Int>(
|
||||
open val intDefaults = mapOf(
|
||||
Pair(App.OVERRIDE_PROXY_PORT, App.OVERRIDE_PROXY_PORT_DEFAULT)
|
||||
)
|
||||
|
||||
open val longDefaults = mapOf<String, Long>()
|
||||
|
||||
open val stringDefaults = mapOf<String, String>(
|
||||
open val stringDefaults = mapOf(
|
||||
Pair(App.OVERRIDE_PROXY_HOST, App.OVERRIDE_PROXY_HOST_DEFAULT)
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import java.io.Closeable
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
@@ -216,8 +215,8 @@ class Settings: Service(), Provider.Observer {
|
||||
class Stub(
|
||||
delegate: ISettings,
|
||||
private val context: Context,
|
||||
private val serviceConn: ServiceConnection
|
||||
): ISettings by delegate, Closeable {
|
||||
private val serviceConn: ServiceConnection?
|
||||
): ISettings by delegate, AutoCloseable {
|
||||
|
||||
override fun close() {
|
||||
try {
|
||||
|
||||
@@ -20,8 +20,8 @@ class SharedPreferencesProvider(
|
||||
): Provider {
|
||||
|
||||
companion object {
|
||||
private val META_VERSION = "version"
|
||||
private val CURRENT_VERSION = 0
|
||||
private const val META_VERSION = "version"
|
||||
private const val CURRENT_VERSION = 0
|
||||
}
|
||||
|
||||
private val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
@@ -72,14 +72,14 @@ class SharedPreferencesProvider(
|
||||
Pair(true, true)
|
||||
|
||||
private fun<T> putValue(key: String, value: T?, writer: (SharedPreferences.Editor, T) -> Unit): Boolean {
|
||||
if (value == null)
|
||||
return remove(key)
|
||||
return if (value == null)
|
||||
remove(key)
|
||||
else {
|
||||
Logger.log.fine("Writing setting $key = $value")
|
||||
val edit = preferences.edit()
|
||||
writer(edit, value)
|
||||
edit.apply()
|
||||
return true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,28 +7,92 @@
|
||||
*/
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.*
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.DatabaseUtils
|
||||
import android.os.Bundle
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class AccountAuthenticatorService: Service() {
|
||||
|
||||
/**
|
||||
* Account authenticator for the main DAVdroid account type.
|
||||
*
|
||||
* Gets started when a DAVdroid account is removed, too, so it also watches for account removals
|
||||
* and contains the corresponding cleanup code.
|
||||
*/
|
||||
class AccountAuthenticatorService: Service(), OnAccountsUpdateListener {
|
||||
|
||||
companion object {
|
||||
|
||||
fun cleanupAccounts(context: Context) {
|
||||
Logger.log.info("Cleaning up orphaned accounts")
|
||||
|
||||
ServiceDB.OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
val sqlAccountNames = LinkedList<String>()
|
||||
val accountNames = HashSet<String>()
|
||||
val accountManager = AccountManager.get(context)
|
||||
for (account in accountManager.getAccountsByType(context.getString(R.string.account_type))) {
|
||||
sqlAccountNames.add(DatabaseUtils.sqlEscapeString(account.name))
|
||||
accountNames += account.name
|
||||
}
|
||||
|
||||
// delete orphaned address book accounts
|
||||
accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))
|
||||
.map { LocalAddressBook(context, it, null) }
|
||||
.forEach {
|
||||
try {
|
||||
if (!accountNames.contains(it.mainAccount.name))
|
||||
it.delete()
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't delete address book account", e)
|
||||
}
|
||||
}
|
||||
|
||||
// delete orphaned services in DB
|
||||
if (sqlAccountNames.isEmpty())
|
||||
db.delete(ServiceDB.Services._TABLE, null, null)
|
||||
else
|
||||
db.delete(ServiceDB.Services._TABLE, "${ServiceDB.Services.ACCOUNT_NAME} NOT IN (${sqlAccountNames.joinToString(",")})", null)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private lateinit var accountManager: AccountManager
|
||||
private lateinit var accountAuthenticator: AccountAuthenticator
|
||||
|
||||
override fun onCreate() {
|
||||
accountManager = AccountManager.get(this)
|
||||
accountManager.addOnAccountsUpdatedListener(this, null, true)
|
||||
|
||||
accountAuthenticator = AccountAuthenticator(this)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
accountManager.removeOnAccountsUpdatedListener(this)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) =
|
||||
accountAuthenticator.iBinder.takeIf { intent?.action == android.accounts.AccountManager.ACTION_AUTHENTICATOR_INTENT }
|
||||
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<out Account>?) {
|
||||
cleanupAccounts(this)
|
||||
}
|
||||
|
||||
|
||||
private class AccountAuthenticator(
|
||||
val context: Context
|
||||
): AbstractAccountAuthenticator(context) {
|
||||
|
||||
@@ -21,7 +21,7 @@ import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
@@ -29,11 +29,11 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
override fun syncAdapter() = AddressBooksSyncAdapter(this)
|
||||
|
||||
|
||||
protected class AddressBooksSyncAdapter(
|
||||
class AddressBooksSyncAdapter(
|
||||
context: Context
|
||||
): SyncAdapter(context) {
|
||||
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, addressBooksProvider: ContentProviderClient, syncResult: SyncResult) {
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
val contactsProvider = context.contentResolver.acquireContentProviderClient(ContactsContract.AUTHORITY)
|
||||
if (contactsProvider == null) {
|
||||
Logger.log.severe("Couldn't access contacts provider")
|
||||
@@ -82,8 +82,8 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteAddressBooks(service: Long?): MutableMap<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, CollectionInfo>()
|
||||
fun remoteAddressBooks(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
Collections.SERVICE_ID + "=? AND " + Collections.SYNC, arrayOf(service.toString()), null, null, null)?.use { cursor ->
|
||||
@@ -103,8 +103,8 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
val remote = remoteAddressBooks(service)
|
||||
|
||||
// delete/update local address books
|
||||
for (addressBook in LocalAddressBook.find(context, provider, account)) {
|
||||
val url = addressBook.getURL()
|
||||
for (addressBook in LocalAddressBook.findAll(context, provider, account)) {
|
||||
val url = HttpUrl.parse(addressBook.url)!!
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local address book", url)
|
||||
@@ -114,7 +114,7 @@ class AddressBooksSyncAdapterService: SyncAdapterService() {
|
||||
try {
|
||||
Logger.log.log(Level.FINE, "Updating local address book $url", info)
|
||||
addressBook.update(info)
|
||||
} catch(e: ContactsStorageException) {
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.WARNING, "Couldn't rename address book account", e)
|
||||
}
|
||||
// we already have a local address book for this remote collection, don't take into consideration anymore
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4android.DavCollection
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.exception.ConflictException
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.dav4android.exception.PreconditionFailedException
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class BaseDavSyncManager<ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>, RemoteType: DavCollection>(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
account: Account,
|
||||
accountSettings: AccountSettings,
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
localCollection: CollectionType
|
||||
): SyncManager<ResourceType, CollectionType>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection), AutoCloseable {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How many updates are requested per collection-sync REPORT. We use a rather small number
|
||||
* because the response has to be parsed and held in memory, which is a spare resource on
|
||||
* handheld devices.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
const val COLLECTION_SYNC_LIMIT = 1
|
||||
}
|
||||
|
||||
protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()
|
||||
|
||||
protected lateinit var collectionURL: HttpUrl
|
||||
protected lateinit var davCollection: RemoteType
|
||||
|
||||
protected var hasCollectionSync = false
|
||||
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
}
|
||||
|
||||
override fun prepare() = true
|
||||
|
||||
protected fun syncState(dav: DavResponse) =
|
||||
dav[SyncToken::class.java]?.token?.let {
|
||||
SyncState(SyncState.Type.SYNC_TOKEN, it)
|
||||
} ?:
|
||||
dav[GetCTag::class.java]?.cTag?.let {
|
||||
SyncState(SyncState.Type.CTAG, it)
|
||||
}
|
||||
|
||||
override fun querySyncState(): SyncState? =
|
||||
davCollection.propfind(0, GetCTag.NAME, SyncToken.NAME).use {
|
||||
syncState(it)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process locally deleted entries (DELETE them on the server as well).
|
||||
* Checks for thread interruption before each request to allow quick sync cancellation.
|
||||
*/
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
var numDeleted = 0
|
||||
|
||||
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
val localList = localCollection.findDeleted()
|
||||
for (local in localList)
|
||||
useLocal(local, {
|
||||
abortIfCancelled()
|
||||
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server")
|
||||
|
||||
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build()), { remote ->
|
||||
try {
|
||||
remote.delete(local.eTag)
|
||||
numDeleted++
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
|
||||
}
|
||||
})
|
||||
} else
|
||||
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
})
|
||||
Logger.log.info("Removed $numDeleted record(s) from server")
|
||||
return numDeleted > 0
|
||||
}
|
||||
|
||||
protected abstract fun prepareUpload(resource: ResourceType): RequestBody
|
||||
|
||||
/**
|
||||
* Uploads dirty records to the server, using a PUT request for each record.
|
||||
* Checks for thread interruption before each request to allow quick sync cancellation.
|
||||
*/
|
||||
override fun uploadDirty(): Boolean {
|
||||
var numUploaded = 0
|
||||
|
||||
// upload dirty contacts
|
||||
for (local in localCollection.findDirty())
|
||||
useLocal(local, {
|
||||
abortIfCancelled()
|
||||
|
||||
if (local.fileName == null) {
|
||||
Logger.log.fine("Generating file name/UID for local record #${local.id}")
|
||||
local.assignNameAndUID()
|
||||
}
|
||||
|
||||
val fileName = local.fileName!!
|
||||
useRemote(DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build()), { remote ->
|
||||
// generate entity to upload (VCard, iCal, whatever)
|
||||
val body = prepareUpload(local)
|
||||
|
||||
var response: DavResponse? = null
|
||||
try {
|
||||
response = if (local.eTag == null) {
|
||||
Logger.log.info("Uploading new record $fileName")
|
||||
remote.put(body, null, true)
|
||||
} else {
|
||||
Logger.log.info("Uploading locally modified record $fileName")
|
||||
remote.put(body, local.eTag, false)
|
||||
}
|
||||
numUploaded++
|
||||
} catch(e: ConflictException) {
|
||||
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
|
||||
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
|
||||
} catch(e: PreconditionFailedException) {
|
||||
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
|
||||
} finally {
|
||||
response?.close()
|
||||
}
|
||||
|
||||
val newETag = response?.get(GetETag::class.java)
|
||||
val eTag: String?
|
||||
if (newETag != null) {
|
||||
eTag = newETag.eTag
|
||||
Logger.log.fine("Received new ETag=$eTag after uploading")
|
||||
} else {
|
||||
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
eTag = null
|
||||
}
|
||||
|
||||
local.clearDirty(eTag)
|
||||
})
|
||||
})
|
||||
Logger.log.info("Sent $numUploaded record(s) to server")
|
||||
return numUploaded > 0
|
||||
}
|
||||
|
||||
override fun syncRequired(state: SyncState?): Boolean {
|
||||
if (syncAlgorithm() == SyncAlgorithm.PROPFIND_REPORT && extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
|
||||
Logger.log.info("Manual sync in PROPFIND/REPORT mode, forcing sync")
|
||||
return true
|
||||
}
|
||||
|
||||
val localState = localCollection.lastSyncState
|
||||
Logger.log.info("Local sync state = $localState, remote sync state = $state")
|
||||
return when {
|
||||
state?.type == SyncState.Type.SYNC_TOKEN -> {
|
||||
val lastKnownToken = localState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }?.value
|
||||
lastKnownToken != state.value
|
||||
}
|
||||
state?.type == SyncState.Type.CTAG -> {
|
||||
val lastKnownCTag = localState?.takeIf { it.type == SyncState.Type.CTAG }?.value
|
||||
lastKnownCTag != state.value
|
||||
}
|
||||
else ->
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun resetPresentRemotely() {
|
||||
val number = localCollection.markNotDirty(0)
|
||||
Logger.log.info("Number of local non-dirty entries: $number")
|
||||
}
|
||||
|
||||
override fun compareLocalRemote(remoteResources: Map<String, DavResponse>): RemoteChanges {
|
||||
/* check which resources are
|
||||
1. updated remotely -> update
|
||||
2. added remotely -> update
|
||||
3. not present remotely anymore -> ignore (because they will be deleted by deleteObsolete()
|
||||
*/
|
||||
|
||||
val changes = RemoteChanges(null, false)
|
||||
|
||||
for ((name, remote) in remoteResources)
|
||||
useLocal(localCollection.findByName(name), { local ->
|
||||
if (local == null) {
|
||||
Logger.log.info("$name has been added remotely")
|
||||
changes.updated += remote
|
||||
} else {
|
||||
val localETag = local.eTag
|
||||
val remoteETag = remote[GetETag::class.java]?.eTag ?: throw DavException("Server didn't provide ETag")
|
||||
if (localETag == remoteETag)
|
||||
Logger.log.fine("$name has not been changed on server (ETag still $remoteETag)")
|
||||
else {
|
||||
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
changes.updated += remote
|
||||
}
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
}
|
||||
})
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
override fun listRemoteChanges(state: SyncState?): RemoteChanges {
|
||||
val dav = /*try {
|
||||
davCollection.reportChanges(
|
||||
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, COLLECTION_SYNC_LIMIT,
|
||||
GetETag.NAME)
|
||||
} catch(e: HttpException) {
|
||||
if (e.status == 507)*/
|
||||
// some servers don't like the limit, try again without
|
||||
davCollection.reportChanges(
|
||||
state?.takeIf { state.type == SyncState.Type.SYNC_TOKEN }?.value,
|
||||
false, null,
|
||||
GetETag.NAME)
|
||||
/*else
|
||||
throw e
|
||||
}*/
|
||||
dav.use {
|
||||
val changes = RemoteChanges(dav.syncToken?.let { SyncState.fromSyncToken(it) }, dav.furtherResults)
|
||||
for (member in dav.members) {
|
||||
// ignore if resource is existing locally with same ETag
|
||||
// (happens at initial sync, when resources are already present locally)
|
||||
var skip = false
|
||||
localCollection.findByName(member.fileName())?.let { local ->
|
||||
member[GetETag::class.java]?.eTag?.let { remoteETag ->
|
||||
if (local.eTag == remoteETag) {
|
||||
Logger.log.info("${local.fileName} is already available with ETag $remoteETag, skipping update")
|
||||
skip = true
|
||||
|
||||
// mark as remotely present, so that this resource won't be deleted at the end
|
||||
local.updateFlags(LocalResource.FLAG_REMOTELY_PRESENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip)
|
||||
changes.updated += member
|
||||
}
|
||||
|
||||
for (member in dav.removedMembers)
|
||||
changes.deleted += member.fileName()
|
||||
|
||||
Logger.log.log(Level.INFO, "Received list of changed/removed resources", changes)
|
||||
return changes
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteNotPresentRemotely() {
|
||||
val removed = localCollection.removeNotDirtyMarked(0)
|
||||
Logger.log.info("Removed $removed local resources which are not present on the server anymore")
|
||||
}
|
||||
|
||||
override fun postProcess() {
|
||||
}
|
||||
|
||||
|
||||
protected fun<T: LocalResource<*>?, R> useLocal(local: T, body: (T) -> R): R {
|
||||
local?.let { currentLocalResource.push(it) }
|
||||
val result = body(local)
|
||||
local?.let { currentLocalResource.pop() }
|
||||
return result
|
||||
}
|
||||
|
||||
protected fun<T: DavResource, R> useRemote(remote: T, body: (T) -> R): R {
|
||||
currentRemoteResource.push(remote.location)
|
||||
val result = body(remote)
|
||||
currentRemoteResource.pop()
|
||||
return result
|
||||
}
|
||||
|
||||
protected fun<T> useRemote(remote: DavResponse, body: (DavResponse) -> T): T {
|
||||
currentRemoteResource.push(remote.url)
|
||||
val result = body(remote)
|
||||
currentRemoteResource.pop()
|
||||
return result
|
||||
}
|
||||
|
||||
protected fun<R> useRemoteCollection(body: (RemoteType) -> R) =
|
||||
useRemote(davCollection, body)
|
||||
|
||||
}
|
||||
@@ -9,18 +9,15 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.ContentProviderClient
|
||||
import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4android.DavCalendar
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.CalendarData
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.resource.LocalEvent
|
||||
@@ -30,7 +27,6 @@ import at.bitfire.ical4android.Event
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
@@ -38,7 +34,7 @@ import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles events ({@code VEVENT}).
|
||||
* Synchronization manager for CalDAV collections; handles events (VEVENT)
|
||||
*/
|
||||
class CalendarSyncManager(
|
||||
context: Context,
|
||||
@@ -48,53 +44,58 @@ class CalendarSyncManager(
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
val localCalendar: LocalCalendar
|
||||
): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, "calendar/${localCalendar.id}") {
|
||||
localCalendar: LocalCalendar
|
||||
): BaseDavSyncManager<LocalEvent, LocalCalendar, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCalendar) {
|
||||
|
||||
val MAX_MULTIGET = 20
|
||||
|
||||
init {
|
||||
localCollection = localCalendar
|
||||
companion object {
|
||||
const val MULTIGET_MAX_RESOURCES = 30
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_CALENDAR_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_calendar, account.name)!!
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = HttpUrl.parse(localCalendar.name ?: return false) ?: return false
|
||||
if (!super.prepare())
|
||||
return false
|
||||
|
||||
collectionURL = HttpUrl.parse(localCollection.name ?: return false) ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
// if there are dirty exceptions for events, mark their master events as dirty, too
|
||||
localCollection.processDirtyExceptions()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
davCollection.propfind(0, GetCTag.NAME)
|
||||
}
|
||||
override fun queryCapabilities() =
|
||||
useRemoteCollection {
|
||||
it.propfind(0, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME).use { dav ->
|
||||
dav[SupportedReportSet::class.java]?.let {
|
||||
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
|
||||
|
||||
override fun prepareDirty() {
|
||||
super.prepareDirty()
|
||||
localCalendar.processDirtyExceptions()
|
||||
}
|
||||
syncState(dav)
|
||||
}
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
if (resource is LocalEvent) {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
override fun syncAlgorithm() = if (accountSettings.getTimeRangePastDays() != null || !hasCollectionSync)
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
else
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
override fun prepareUpload(resource: LocalEvent): RequestBody = useLocal(resource, {
|
||||
val event = requireNotNull(resource.event)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of event ${resource.fileName}", event)
|
||||
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
os.toByteArray()
|
||||
)
|
||||
} else
|
||||
throw IllegalArgumentException("resource must be a LocalEvent")
|
||||
}
|
||||
val os = ByteArrayOutputStream()
|
||||
event.write(os)
|
||||
|
||||
override fun listRemote() {
|
||||
RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
})
|
||||
|
||||
override fun listAllRemote(): Map<String, DavResponse> {
|
||||
// calculate time range limits
|
||||
var limitStart: Date? = null
|
||||
accountSettings.getTimeRangePastDays()?.let { pastDays ->
|
||||
@@ -103,77 +104,74 @@ class CalendarSyncManager(
|
||||
limitStart = calendar.time
|
||||
}
|
||||
|
||||
// fetch list of remote VEVENTs and build hash table to index file name
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.calendarQuery("VEVENT", limitStart, null)
|
||||
|
||||
remoteResources = HashMap(davCollection.members.size)
|
||||
for (iCal in davCollection.members) {
|
||||
val fileName = iCal.fileName()
|
||||
Logger.log.fine("Found remote VEVENT: $fileName")
|
||||
remoteResources[fileName] = iCal
|
||||
return useRemoteCollection { remote ->
|
||||
// fetch list of remote VEVENTs and build hash table to index file name
|
||||
Logger.log.info("Querying events since $limitStart")
|
||||
remote.calendarQuery("VEVENT", limitStart, null).use { dav ->
|
||||
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
|
||||
for (iCal in dav.members) {
|
||||
val fileName = iCal.fileName()
|
||||
Logger.log.fine("Found remote VEVENT: $fileName")
|
||||
result[fileName] = iCal
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} events ($MAX_MULTIGET at once)")
|
||||
override fun processRemoteChanges(changes: RemoteChanges) {
|
||||
for (name in changes.deleted)
|
||||
localCollection.findByName(name)?.let {
|
||||
Logger.log.info("Deleting local event $name")
|
||||
useLocal(it, { local -> local.delete() })
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
val toDownload = changes.updated.map { it.url }
|
||||
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
|
||||
if (bunch.size == 1)
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), {
|
||||
it.get(DavCalendar.MIME_ICALENDAR.toString()).use { dav ->
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = dav[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received CalDAV GET response without ETag for ${dav.url}")
|
||||
|
||||
val body = remote.get("text/calendar")
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream()?.use { reader ->
|
||||
processVEvent(remote.fileName(), eTag.eTag!!, reader)
|
||||
}
|
||||
|
||||
} else {
|
||||
dav.body?.charStream()?.use { reader ->
|
||||
processVEvent(dav.fileName(), eTag, reader)
|
||||
}
|
||||
}
|
||||
})
|
||||
else {
|
||||
// multiple contacts, use multi-get
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.multiget(bunch.map { it.location })
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch).use { dav ->
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
// process multiget results
|
||||
for (remote in dav.members)
|
||||
useRemote(remote, {
|
||||
val eTag = remote[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
val calendarData = remote[CalendarData::class.java]
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
|
||||
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without event data")
|
||||
|
||||
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
processVEvent(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
abortIfCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davCalendar() = davCollection as DavCalendar
|
||||
|
||||
private fun processVEvent(fileName: String, eTag: String, reader: Reader) {
|
||||
val events: List<Event>
|
||||
try {
|
||||
@@ -187,24 +185,22 @@ class CalendarSyncManager(
|
||||
val newData = events.first()
|
||||
|
||||
// delete local event, if it exists
|
||||
val localEvent = localResources[fileName] as LocalEvent?
|
||||
currentLocalResource = localEvent
|
||||
if (localEvent != null) {
|
||||
Logger.log.info("Updating $fileName in local calendar")
|
||||
localEvent.eTag = eTag
|
||||
localEvent.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local calendar")
|
||||
val newEvent = LocalEvent(localCalendar, newData, fileName, eTag)
|
||||
currentLocalResource = newEvent
|
||||
newEvent.add()
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
useLocal(localCollection.findByName(fileName), { local ->
|
||||
if (local != null) {
|
||||
Logger.log.info("Updating $fileName in local calendar")
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local calendar")
|
||||
useLocal(LocalEvent(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
|
||||
it.add()
|
||||
})
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
})
|
||||
} else
|
||||
Logger.log.severe("Received VCALENDAR with not exactly one VEVENT with UID, but without RECURRENCE-ID; ignoring $fileName")
|
||||
|
||||
currentLocalResource = null
|
||||
Logger.log.info("Received VCALENDAR with not exactly one VEVENT with UID and without RECURRENCE-ID; ignoring $fileName")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalCalendar
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.ical4android.AndroidCalendar
|
||||
import okhttp3.HttpUrl
|
||||
import java.util.logging.Level
|
||||
|
||||
class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
@@ -51,7 +52,7 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, "${CalendarContract.Calendars.SYNC_EVENTS}!=0", null)) {
|
||||
Logger.log.info("Synchronizing calendar #${calendar.id}, URL: ${calendar.name}")
|
||||
CalendarSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, provider, calendar).use {
|
||||
CalendarSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, calendar).use {
|
||||
it.performSync()
|
||||
}
|
||||
}
|
||||
@@ -75,8 +76,8 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteCalendars(service: Long?): MutableMap<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, CollectionInfo>()
|
||||
fun remoteCalendars(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VEVENT}!=0 AND ${Collections.SYNC}",
|
||||
@@ -100,7 +101,8 @@ class CalendarsSyncAdapterService: SyncAdapterService() {
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (calendar in AndroidCalendar.find(account, provider, LocalCalendar.Factory, null, null))
|
||||
calendar.name?.let { url ->
|
||||
calendar.name?.let {
|
||||
val url = HttpUrl.parse(it)!!
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.log(Level.INFO, "Deleting obsolete local calendar", url)
|
||||
|
||||
@@ -24,7 +24,7 @@ import java.util.logging.Level
|
||||
class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
companion object {
|
||||
val PREVIOUS_GROUP_METHOD = "previous_group_method"
|
||||
const val PREVIOUS_GROUP_METHOD = "previous_group_method"
|
||||
}
|
||||
|
||||
override fun syncAdapter() = ContactsSyncAdapter(this)
|
||||
@@ -37,7 +37,7 @@ class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, account, provider)
|
||||
val accountSettings = AccountSettings(context, settings, addressBook.getMainAccount())
|
||||
val accountSettings = AccountSettings(context, settings, addressBook.mainAccount)
|
||||
|
||||
// handle group method change
|
||||
val groupMethod = accountSettings.getGroupMethod().name
|
||||
@@ -49,8 +49,8 @@ class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
provider.delete(addressBook.syncAdapterURI(ContactsContract.RawContacts.CONTENT_URI), null, null)
|
||||
provider.delete(addressBook.syncAdapterURI(ContactsContract.Groups.CONTENT_URI), null, null)
|
||||
|
||||
// reset CTag
|
||||
addressBook.setCTag(null)
|
||||
// reset sync state
|
||||
addressBook.syncState = null
|
||||
}
|
||||
}
|
||||
accountSettings.accountManager.setUserData(account, PREVIOUS_GROUP_METHOD, groupMethod)
|
||||
@@ -58,8 +58,8 @@ class ContactsSyncAdapterService: SyncAdapterService() {
|
||||
if (!extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL) && !checkSyncConditions(accountSettings))
|
||||
return
|
||||
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.getURL()}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.getMainAccount()}")
|
||||
Logger.log.info("Synchronizing address book: ${addressBook.url}")
|
||||
Logger.log.info("Taking settings from: ${addressBook.mainAccount}")
|
||||
|
||||
ContactsSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, provider, addressBook).use {
|
||||
it.performSync()
|
||||
|
||||
@@ -12,68 +12,66 @@ import android.accounts.Account
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.ContactsContract
|
||||
import android.provider.ContactsContract.Groups
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import at.bitfire.dav4android.DavAddressBook
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.*
|
||||
import at.bitfire.davdroid.*
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalContact
|
||||
import at.bitfire.davdroid.resource.LocalGroup
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.*
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.vcard4android.BatchOperation
|
||||
import at.bitfire.vcard4android.Contact
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
import ezvcard.VCardVersion
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* <p>Synchronization manager for CardDAV collections; handles contacts and groups.</p>
|
||||
* Synchronization manager for CardDAV collections; handles contacts and groups.
|
||||
*
|
||||
* <p></p>Group handling differs according to the {@link #groupMethod}. There are two basic methods to
|
||||
* handle/manage groups:</p>
|
||||
* <ul>
|
||||
* <li>{@code CATEGORIES}: groups memberships are attached to each contact and represented as
|
||||
* "category". When a group is dirty or has been deleted, all its members have to be set to
|
||||
* dirty, too (because they have to be uploaded without the respective category). This
|
||||
* is done in {@link #prepareDirty()}. Empty groups can be deleted without further processing,
|
||||
* which is done in {@link #postProcess()} because groups may become empty after downloading
|
||||
* updated remote contacts.</li>
|
||||
* <li>Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
|
||||
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
|
||||
* <ol>
|
||||
* <li>However, when a contact is dirty, it has
|
||||
* to be checked whether its group memberships have changed. In this case, the respective
|
||||
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
|
||||
* group membership of G is removed, the contact will be set to dirty because of the changed
|
||||
* {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}. DAVdroid will
|
||||
* then have to check whether the group memberships have actually changed, and if so,
|
||||
* all affected groups have to be set to dirty. To detect changes in group memberships,
|
||||
* DAVdroid always mirrors all {@link android.provider.ContactsContract.CommonDataKinds.GroupMembership}
|
||||
* data rows in respective {@link at.bitfire.vcard4android.CachedGroupMembership} rows.
|
||||
* If the cached group memberships are not the same as the current group member ships, the
|
||||
* difference set (in our example G, because its in the cached memberships, but not in the
|
||||
* actual ones) is marked as dirty. This is done in {@link #prepareDirty()}.</li>
|
||||
* <li>When downloading remote contacts, groups (+ member information) may be received
|
||||
* by the actual members. Thus, the member lists have to be cached until all VCards
|
||||
* are received. This is done by caching the member UIDs of each group in
|
||||
* {@link LocalGroup#COLUMN_PENDING_MEMBERS}. In {@link #postProcess()},
|
||||
* these "pending memberships" are assigned to the actual contacs and then cleaned up.</li>
|
||||
* </ol>
|
||||
* </ul>
|
||||
* Group handling differs according to the {@link #groupMethod}. There are two basic methods to
|
||||
* handle/manage groups:
|
||||
*
|
||||
* 1. CATEGORIES: groups memberships are attached to each contact and represented as
|
||||
* "category". When a group is dirty or has been deleted, all its members have to be set to
|
||||
* dirty, too (because they have to be uploaded without the respective category). This
|
||||
* is done in [uploadDirty]. Empty groups can be deleted without further processing,
|
||||
* which is done in [postProcess] because groups may become empty after downloading
|
||||
* updated remote contacts.
|
||||
*
|
||||
* 2. Groups as separate VCards: individual and group contacts (with a list of member UIDs) are
|
||||
* distinguished. When a local group is dirty, its members don't need to be set to dirty.
|
||||
*
|
||||
* However, when a contact is dirty, it has
|
||||
* to be checked whether its group memberships have changed. In this case, the respective
|
||||
* groups have to be set to dirty. For instance, if contact A is in group G and H, and then
|
||||
* group membership of G is removed, the contact will be set to dirty because of the changed
|
||||
* [android.provider.ContactsContract.CommonDataKinds.GroupMembership]. DAVdroid will
|
||||
* then have to check whether the group memberships have actually changed, and if so,
|
||||
* all affected groups have to be set to dirty. To detect changes in group memberships,
|
||||
* DAVdroid always mirrors all [android.provider.ContactsContract.CommonDataKinds.GroupMembership]
|
||||
* data rows in respective [at.bitfire.vcard4android.CachedGroupMembership] rows.
|
||||
* If the cached group memberships are not the same as the current group member ships, the
|
||||
* difference set (in our example G, because its in the cached memberships, but not in the
|
||||
* actual ones) is marked as dirty. This is done in [uploadDirty].
|
||||
*
|
||||
* When downloading remote contacts, groups (+ member information) may be received
|
||||
* by the actual members. Thus, the member lists have to be cached until all VCards
|
||||
* are received. This is done by caching the member UIDs of each group in
|
||||
* [LocalGroup.COLUMN_PENDING_MEMBERS]. In [postProcess],
|
||||
* these "pending memberships" are assigned to the actual contacts and then cleaned up.
|
||||
*/
|
||||
class ContactsSyncManager(
|
||||
context: Context,
|
||||
@@ -84,100 +82,102 @@ class ContactsSyncManager(
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: ContentProviderClient,
|
||||
private val localAddressBook: LocalAddressBook
|
||||
): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, "addressBook") {
|
||||
localAddressBook: LocalAddressBook
|
||||
): BaseDavSyncManager<LocalAddress, LocalAddressBook, DavAddressBook>(context, settings, account, accountSettings, extras, authority, syncResult, localAddressBook) {
|
||||
|
||||
companion object {
|
||||
private val MAX_MULTIGET = 10
|
||||
private const val MULTIGET_MAX_RESOURCES = 10
|
||||
|
||||
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
|
||||
}
|
||||
|
||||
private val readOnly = localAddressBook.getReadOnly()
|
||||
private val readOnly = localAddressBook.readOnly
|
||||
private var numDiscarded = 0
|
||||
|
||||
private var hasVCard4 = false
|
||||
private val groupMethod = accountSettings.getGroupMethod()
|
||||
|
||||
|
||||
init {
|
||||
localCollection = localAddressBook
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_CONTACTS_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_contacts, account.name)!!
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
if (!super.prepare())
|
||||
return false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
val reallyDirty = localAddressBook.verifyDirty()
|
||||
val deleted = localAddressBook.getDeleted().size
|
||||
val reallyDirty = localCollection.verifyDirty()
|
||||
val deleted = localCollection.findDeleted().size
|
||||
if (extras.containsKey(ContentResolver.SYNC_EXTRAS_UPLOAD) && reallyDirty == 0 && deleted == 0) {
|
||||
Logger.log.info("This sync was called to up-sync dirty/deleted contacts, but no contacts have been changed")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// set up Contacts Provider Settings
|
||||
val values = ContentValues(2)
|
||||
values.put(ContactsContract.Settings.SHOULD_SYNC, 1)
|
||||
values.put(ContactsContract.Settings.UNGROUPED_VISIBLE, 1)
|
||||
localAddressBook.updateSettings(values)
|
||||
|
||||
collectionURL = HttpUrl.parse(localAddressBook.getURL()) ?: return false
|
||||
collectionURL = HttpUrl.parse(localCollection.url) ?: return false
|
||||
davCollection = DavAddressBook(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
// prepare remote address book
|
||||
davCollection.propfind(0, SupportedAddressData.NAME, GetCTag.NAME)
|
||||
(davCollection.properties[SupportedAddressData.NAME] as SupportedAddressData?)?.let {
|
||||
hasVCard4 = it.hasVCard4()
|
||||
}
|
||||
Logger.log.info("Server advertises VCard/4 support: $hasVCard4")
|
||||
|
||||
override fun queryCapabilities(): SyncState? {
|
||||
Logger.log.info("Contact group method: $groupMethod")
|
||||
// in case of GROUP_VCARDs, treat groups as contacts in the local address book
|
||||
localAddressBook.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
localCollection.includeGroups = groupMethod == GroupMethod.GROUP_VCARDS
|
||||
|
||||
return useRemoteCollection {
|
||||
it.propfind(0, SupportedAddressData.NAME, SupportedReportSet.NAME, GetCTag.NAME, SyncToken.NAME).use { dav ->
|
||||
dav[SupportedAddressData::class.java]?.let {
|
||||
hasVCard4 = it.hasVCard4()
|
||||
}
|
||||
Logger.log.info("Server supports vCard/4: $hasVCard4")
|
||||
|
||||
dav[SupportedReportSet::class.java]?.let {
|
||||
hasCollectionSync = it.reports.contains(SupportedReportSet.SYNC_COLLECTION)
|
||||
}
|
||||
Logger.log.info("Server supports Collection Sync: $hasCollectionSync")
|
||||
|
||||
syncState(dav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun processLocallyDeleted() {
|
||||
override fun syncAlgorithm() = if (hasCollectionSync)
|
||||
SyncAlgorithm.COLLECTION_SYNC
|
||||
else
|
||||
SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun processLocallyDeleted(): Boolean {
|
||||
if (readOnly) {
|
||||
for (group in localAddressBook.getDeletedGroups()) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.warning("Restoring locally deleted group (read-only address book!)")
|
||||
group.resetDeleted()
|
||||
useLocal(group, { it.resetDeleted() })
|
||||
numDiscarded++
|
||||
}
|
||||
|
||||
for (contact in localAddressBook.getDeletedContacts()) {
|
||||
for (contact in localCollection.findDeletedContacts()) {
|
||||
Logger.log.warning("Restoring locally deleted contact (read-only address book!)")
|
||||
contact.resetDeleted()
|
||||
useLocal(contact, { it.resetDeleted() })
|
||||
numDiscarded++
|
||||
}
|
||||
|
||||
if (numDiscarded > 0)
|
||||
notifyDiscardedChange()
|
||||
return false
|
||||
} else
|
||||
// mirror deletions to remote collection (DELETE)
|
||||
super.processLocallyDeleted()
|
||||
return super.processLocallyDeleted()
|
||||
}
|
||||
|
||||
override fun prepareDirty() {
|
||||
// generate UID/file name for newly created contacts
|
||||
super.prepareDirty()
|
||||
|
||||
override fun uploadDirty(): Boolean {
|
||||
if (readOnly) {
|
||||
for (group in localAddressBook.getDirtyGroups()) {
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.warning("Resetting locally modified group to ETag=null (read-only address book!)")
|
||||
group.clearDirty(null)
|
||||
useLocal(group, { it.clearDirty(null) })
|
||||
numDiscarded++
|
||||
}
|
||||
|
||||
for (contact in localAddressBook.getDirtyContacts()) {
|
||||
for (contact in localCollection.findDirtyContacts()) {
|
||||
Logger.log.warning("Resetting locally modified contact to ETag=null (read-only address book!)")
|
||||
contact.clearDirty(null)
|
||||
useLocal(contact, { it.clearDirty(null) })
|
||||
numDiscarded++
|
||||
}
|
||||
|
||||
@@ -189,25 +189,27 @@ class ContactsSyncManager(
|
||||
/* groups memberships are represented as contact CATEGORIES */
|
||||
|
||||
// groups with DELETED=1: set all members to dirty, then remove group
|
||||
for (group in localAddressBook.getDeletedGroups()) {
|
||||
for (group in localCollection.findDeletedGroups()) {
|
||||
Logger.log.fine("Finally removing group $group")
|
||||
// useless because Android deletes group memberships as soon as a group is set to DELETED:
|
||||
// group.markMembersDirty()
|
||||
group.delete()
|
||||
useLocal(group, { it.delete() })
|
||||
}
|
||||
|
||||
// groups with DIRTY=1: mark all memberships as dirty, then clean DIRTY flag of group
|
||||
for (group in localAddressBook.getDirtyGroups()) {
|
||||
for (group in localCollection.findDirtyGroups()) {
|
||||
Logger.log.fine("Marking members of modified group $group as dirty")
|
||||
group.markMembersDirty()
|
||||
group.clearDirty(null)
|
||||
useLocal(group, {
|
||||
it.markMembersDirty()
|
||||
it.clearDirty(null)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
/* groups as separate VCards: there are group contacts and individual contacts */
|
||||
|
||||
// mark groups with changed members as dirty
|
||||
val batch = BatchOperation(localAddressBook.provider!!)
|
||||
for (contact in localAddressBook.getDirtyContacts())
|
||||
val batch = BatchOperation(localCollection.provider!!)
|
||||
for (contact in localCollection.findDirtyContacts())
|
||||
try {
|
||||
Logger.log.fine("Looking for changed group memberships of contact ${contact.fileName}")
|
||||
val cachedGroups = contact.getCachedGroupMemberships()
|
||||
@@ -215,7 +217,7 @@ class ContactsSyncManager(
|
||||
for (groupID in cachedGroups disjunct currentGroups) {
|
||||
Logger.log.fine("Marking group as dirty: $groupID")
|
||||
batch.enqueue(BatchOperation.Operation(
|
||||
ContentProviderOperation.newUpdate(localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
|
||||
ContentProviderOperation.newUpdate(localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)))
|
||||
.withValue(Groups.DIRTY, 1)
|
||||
.withYieldAllowed(true)
|
||||
))
|
||||
@@ -225,25 +227,26 @@ class ContactsSyncManager(
|
||||
batch.commit()
|
||||
}
|
||||
}
|
||||
|
||||
// generate UID/file name for newly created contacts
|
||||
return super.uploadDirty()
|
||||
}
|
||||
|
||||
private fun notifyDiscardedChange() {
|
||||
val notification = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC_STATUS)
|
||||
val notification = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_STATUS)
|
||||
.setSmallIcon(R.drawable.ic_delete_notification)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(context.getString(R.string.sync_contacts_read_only_address_book))
|
||||
.setContentText(context.resources.getQuantityString(R.plurals.sync_contacts_local_contact_changes_discarded, numDiscarded, numDiscarded))
|
||||
.setNumber(numDiscarded)
|
||||
.setSubText(account.name)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setLocalOnly(true)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
val nm = NotificationUtils.createChannels(context)
|
||||
nm.notify("discarded_${account.name}", 0, notification)
|
||||
notificationManager.notify("discarded_${account.name}", 0, notification)
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
override fun prepareUpload(resource: LocalAddress): RequestBody = useLocal(resource, {
|
||||
val contact: Contact
|
||||
if (resource is LocalContact) {
|
||||
contact = resource.contact!!
|
||||
@@ -251,111 +254,107 @@ class ContactsSyncManager(
|
||||
if (groupMethod == GroupMethod.CATEGORIES) {
|
||||
// add groups as CATEGORIES
|
||||
for (groupID in resource.getGroupMemberships()) {
|
||||
try {
|
||||
provider.query(
|
||||
localAddressBook.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
|
||||
arrayOf(Groups.TITLE), null, null, null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
val title = cursor.getString(0)
|
||||
if (!title.isNullOrEmpty())
|
||||
contact.categories.add(title)
|
||||
}
|
||||
provider.query(
|
||||
localCollection.syncAdapterURI(ContentUris.withAppendedId(Groups.CONTENT_URI, groupID)),
|
||||
arrayOf(Groups.TITLE), null, null, null
|
||||
)?.use { cursor ->
|
||||
if (cursor.moveToNext()) {
|
||||
val title = cursor.getString(0)
|
||||
if (!title.isNullOrEmpty())
|
||||
contact.categories.add(title)
|
||||
}
|
||||
} catch(e: RemoteException) {
|
||||
throw ContactsStorageException("Couldn't find group for adding CATEGORIES", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (resource is LocalGroup)
|
||||
contact = resource.contact!!
|
||||
else
|
||||
throw IllegalArgumentException("resource must be a LocalContact or a LocalGroup")
|
||||
throw IllegalArgumentException("resource must be LocalContact or LocalGroup")
|
||||
|
||||
Logger.log.log(Level.FINE, "Preparing upload of VCard ${resource.fileName}", contact)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
contact.write(if (hasVCard4) VCardVersion.V4_0 else VCardVersion.V3_0, groupMethod, os)
|
||||
|
||||
return RequestBody.create(
|
||||
RequestBody.create(
|
||||
if (hasVCard4) DavAddressBook.MIME_VCARD4 else DavAddressBook.MIME_VCARD3_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
}
|
||||
|
||||
override fun listRemote() {
|
||||
val addressBook = davAddressBook()
|
||||
currentDavResource = addressBook
|
||||
})
|
||||
|
||||
override fun listAllRemote() = useRemoteCollection {
|
||||
// fetch list of remote VCards and build hash table to index file name
|
||||
addressBook.propfind(1, ResourceType.NAME, GetETag.NAME)
|
||||
it.propfind(1, ResourceType.NAME, GetETag.NAME).use { dav ->
|
||||
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
|
||||
for (vCard in dav.members) {
|
||||
// ignore member collections
|
||||
var ignore = false
|
||||
vCard[ResourceType::class.java]?.let { type ->
|
||||
if (type.types.contains(ResourceType.COLLECTION))
|
||||
ignore = true
|
||||
}
|
||||
if (ignore)
|
||||
continue
|
||||
|
||||
remoteResources = HashMap(davCollection.members.size)
|
||||
for (vCard in davCollection.members) {
|
||||
// ignore member collections
|
||||
val type = vCard.properties[ResourceType.NAME] as ResourceType?
|
||||
if (type != null && type.types.contains(ResourceType.COLLECTION))
|
||||
continue
|
||||
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VCard: $fileName")
|
||||
remoteResources[fileName] = vCard
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VCard: $fileName")
|
||||
result[fileName] = vCard
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} contacts ($MAX_MULTIGET at once)")
|
||||
override fun processRemoteChanges(changes: RemoteChanges) {
|
||||
for (name in changes.deleted)
|
||||
localCollection.findByName(name)?.let {
|
||||
Logger.log.info("Deleting local address $name")
|
||||
useLocal(it, { it.delete() })
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
|
||||
val toDownload = changes.updated.map { it.url }
|
||||
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
|
||||
|
||||
// prepare downloader which may be used to download external resource like contact photos
|
||||
val downloader = ResourceDownloader(collectionURL)
|
||||
|
||||
// download new/updated VCards from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
for (bunch in toDownload.chunked(CalendarSyncManager.MULTIGET_MAX_RESOURCES)) {
|
||||
if (bunch.size == 1)
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), {
|
||||
it.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5").use { dav ->
|
||||
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
|
||||
val eTag = dav[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received CardDAV GET response without ETag for ${dav.url}")
|
||||
|
||||
val body = remote.get("text/vcard;version=4.0, text/vcard;charset=utf-8;q=0.8, text/vcard;q=0.5")
|
||||
dav.body?.charStream()?.use { reader ->
|
||||
processVCard(dav.fileName(), eTag, reader, downloader)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// CardDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc6352#section-6.3.2.3]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CardDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVCard(remote.fileName(), eTag.eTag!!, reader, downloader)
|
||||
}
|
||||
|
||||
} else {
|
||||
else {
|
||||
// multiple contacts, use multi-get
|
||||
val addressBook = davAddressBook()
|
||||
currentDavResource = addressBook
|
||||
addressBook.multiget(bunch.map { it.location }, hasVCard4)
|
||||
useRemoteCollection {
|
||||
it.multiget(bunch, hasVCard4).use { dav ->
|
||||
// process multi-get results
|
||||
for (remote in dav.members)
|
||||
useRemote(remote, {
|
||||
val eTag = remote[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
// process multi-get results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
val addressData = remote[AddressData::class.java]
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val addressData = remote.properties[AddressData.NAME] as AddressData?
|
||||
val vCard = addressData?.vCard
|
||||
?: throw DavException("Received multi-get response without address data")
|
||||
|
||||
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
|
||||
processVCard(remote.fileName(), eTag, StringReader(vCard), downloader)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
abortIfCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,20 +364,18 @@ class ContactsSyncManager(
|
||||
|
||||
// remove empty groups
|
||||
Logger.log.info("Removing empty groups")
|
||||
localAddressBook.removeEmptyGroups()
|
||||
localCollection.removeEmptyGroups()
|
||||
|
||||
} else {
|
||||
/* VCard4 group handling: there are group contacts and individual contacts */
|
||||
Logger.log.info("Assigning memberships of downloaded contact groups")
|
||||
LocalGroup.applyPendingMemberships(localAddressBook)
|
||||
LocalGroup.applyPendingMemberships(localCollection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// helpers
|
||||
|
||||
private fun davAddressBook() = davCollection as DavAddressBook
|
||||
|
||||
private fun processVCard(fileName: String, eTag: String, reader: Reader, downloader: Contact.Downloader) {
|
||||
Logger.log.info("Processing CardDAV resource $fileName")
|
||||
val contacts = Contact.fromReader(reader, downloader)
|
||||
@@ -396,71 +393,69 @@ class ContactsSyncManager(
|
||||
}
|
||||
|
||||
// update local contact, if it exists
|
||||
var local = localResources[fileName]
|
||||
currentLocalResource = local
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
useLocal(localCollection.findByName(fileName), {
|
||||
var local = it
|
||||
if (local != null) {
|
||||
Logger.log.log(Level.INFO, "Updating $fileName in local address book", newData)
|
||||
|
||||
if (local is LocalGroup && newData.group) {
|
||||
// update group
|
||||
local.eTag = eTag
|
||||
local.updateFromServer(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
if (local is LocalGroup && newData.group) {
|
||||
// update group
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else if (local is LocalContact && !newData.group) {
|
||||
// update contact
|
||||
local.eTag = eTag
|
||||
local.flags = LocalResource.FLAG_REMOTELY_PRESENT
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
|
||||
} else {
|
||||
// group has become an individual contact or vice versa
|
||||
local.delete()
|
||||
local = null
|
||||
}
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
Logger.log.log(Level.INFO, "Creating local group", newData)
|
||||
val group = LocalGroup(localAddressBook, newData, fileName, eTag)
|
||||
currentLocalResource = group
|
||||
group.create()
|
||||
|
||||
local = group
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
val contact = LocalContact(localAddressBook, newData, fileName, eTag)
|
||||
currentLocalResource = contact
|
||||
contact.create()
|
||||
|
||||
local = contact
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
|
||||
if (groupMethod == GroupMethod.CATEGORIES && local is LocalContact) {
|
||||
// VCard3: update group memberships from CATEGORIES
|
||||
currentLocalResource = local
|
||||
|
||||
val batch = BatchOperation(provider)
|
||||
Logger.log.log(Level.FINE, "Removing contact group memberships")
|
||||
local.removeGroupMemberships(batch)
|
||||
|
||||
for (category in local.contact!!.categories) {
|
||||
val groupID = localAddressBook.findOrCreateGroup(category)
|
||||
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
|
||||
local.addToGroup(batch, groupID)
|
||||
} else {
|
||||
// group has become an individual contact or vice versa
|
||||
local.delete()
|
||||
local = null
|
||||
}
|
||||
}
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
if (local == null) {
|
||||
if (newData.group) {
|
||||
Logger.log.log(Level.INFO, "Creating local group", newData)
|
||||
useLocal(LocalGroup(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
|
||||
it.add()
|
||||
local = it
|
||||
})
|
||||
} else {
|
||||
Logger.log.log(Level.INFO, "Creating local contact", newData)
|
||||
useLocal(LocalContact(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
|
||||
it.add()
|
||||
local = it
|
||||
})
|
||||
}
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O && local is LocalContact)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
local.updateHashCode(null)
|
||||
if (groupMethod == GroupMethod.CATEGORIES)
|
||||
(local as? LocalContact)?.let { localContact ->
|
||||
// VCard3: update group memberships from CATEGORIES
|
||||
val batch = BatchOperation(provider)
|
||||
Logger.log.log(Level.FINE, "Removing contact group memberships")
|
||||
localContact.removeGroupMemberships(batch)
|
||||
|
||||
currentLocalResource = null
|
||||
for (category in localContact.contact!!.categories) {
|
||||
val groupID = localCollection.findOrCreateGroup(category)
|
||||
Logger.log.log(Level.FINE, "Adding membership in group $category ($groupID)")
|
||||
localContact.addToGroup(batch, groupID)
|
||||
}
|
||||
|
||||
batch.commit()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
|
||||
// workaround for Android 7 which sets DIRTY flag when only meta-data is changed
|
||||
(local as? LocalContact)?.updateHashCode(null)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -484,12 +479,7 @@ class ContactsSyncManager(
|
||||
}
|
||||
|
||||
// authenticate only against a certain host, and only upon request
|
||||
val username = accountSettings.username()
|
||||
val password = accountSettings.password()
|
||||
val builder = if (username != null && password != null)
|
||||
HttpClient.Builder(context, baseUrl.host(), username, password)
|
||||
else
|
||||
HttpClient.Builder(context)
|
||||
val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())
|
||||
|
||||
// allow redirects
|
||||
builder.followRedirects(true)
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.AbstractAccountAuthenticator
|
||||
|
||||
@@ -8,33 +8,42 @@
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import android.support.v4.content.ContextCompat
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.davdroid.ui.AccountActivity
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.davdroid.ui.PermissionsActivity
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
abstract class SyncAdapterService: Service() {
|
||||
|
||||
companion object {
|
||||
val runningSyncs = Collections.synchronizedSet(mutableSetOf<Pair<String, Account>>())
|
||||
/** Keep a list of running syncs to block multiple calls at the same time,
|
||||
* like run by some devices. Weak references are used for the case that a thread
|
||||
* is terminated and the `finally` block which cleans up [runningSyncs] is not
|
||||
* executed. */
|
||||
private val runningSyncs = mutableListOf<WeakReference<Pair<String, Account>>>()
|
||||
}
|
||||
|
||||
abstract protected fun syncAdapter(): AbstractThreadedSyncAdapter
|
||||
protected abstract fun syncAdapter(): AbstractThreadedSyncAdapter
|
||||
|
||||
override fun onBind(intent: Intent?) = syncAdapter().syncAdapterBinder!!
|
||||
|
||||
@@ -50,9 +59,12 @@ abstract class SyncAdapterService: Service() {
|
||||
|
||||
// prevent multiple syncs of the same authority to be run for the same account
|
||||
val currentSync = Pair(authority, account)
|
||||
if (!runningSyncs.add(currentSync)) {
|
||||
Logger.log.warning("There's already another $authority sync running for $account, aborting")
|
||||
return
|
||||
synchronized(runningSyncs) {
|
||||
if (runningSyncs.any { it.get() == currentSync }) {
|
||||
Logger.log.warning("There's already another $authority sync running for $account, aborting")
|
||||
return
|
||||
}
|
||||
runningSyncs += WeakReference(currentSync)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -67,31 +79,29 @@ abstract class SyncAdapterService: Service() {
|
||||
return
|
||||
}
|
||||
|
||||
sync(settings, account, extras, authority, provider, syncResult)
|
||||
//if (runSync) {
|
||||
SyncManager.cancelNotifications(NotificationManagerCompat.from(context), authority, account)
|
||||
sync(settings, account, extras, authority, provider, syncResult)
|
||||
//}
|
||||
}
|
||||
Logger.log.info("Sync for $authority complete")
|
||||
} finally {
|
||||
runningSyncs -= currentSync
|
||||
synchronized(runningSyncs) {
|
||||
runningSyncs.removeAll { it.get() == null || it.get() == currentSync }
|
||||
}
|
||||
}
|
||||
|
||||
Logger.log.info("Sync for $currentSync finished")
|
||||
}
|
||||
|
||||
override fun onSecurityException(account: Account, extras: Bundle, authority: String, syncResult: SyncResult) {
|
||||
Logger.log.log(Level.WARNING, "Security exception when opening content provider for $authority")
|
||||
syncResult.databaseError = true
|
||||
|
||||
val intent = Intent(context, PermissionsActivity::class.java)
|
||||
val intent = Intent(context, AccountActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
val notify = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC_PROBLEMS)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(context.getString(R.string.sync_error_permissions))
|
||||
.setContentText(context.getString(R.string.sync_error_permissions_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.build()
|
||||
val nm = NotificationUtils.createChannels(context)
|
||||
nm.notify(Constants.NOTIFICATION_PERMISSIONS, notify)
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
|
||||
protected fun checkSyncConditions(settings: AccountSettings): Boolean {
|
||||
@@ -104,6 +114,17 @@ abstract class SyncAdapterService: Service() {
|
||||
}
|
||||
|
||||
settings.getSyncWifiOnlySSIDs()?.let { onlySSIDs ->
|
||||
// getting the WiFi name requires location permission (and active location services) since Android 8.1
|
||||
// see https://issuetracker.google.com/issues/70633700
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
val intent = Intent(context, AccountSettingsActivity::class.java)
|
||||
intent.putExtra(AccountActivity.EXTRA_ACCOUNT, settings.account)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
|
||||
notifyPermissions(intent)
|
||||
}
|
||||
|
||||
val wifi = context.applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
|
||||
val info = wifi.connectionInfo
|
||||
if (info == null || !onlySSIDs.contains(info.ssid.trim('"'))) {
|
||||
@@ -115,6 +136,18 @@ abstract class SyncAdapterService: Service() {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun notifyPermissions(intent: Intent) {
|
||||
val notify = NotificationUtils.newBuilder(context, NotificationUtils.CHANNEL_SYNC_ERRORS)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setContentTitle(context.getString(R.string.sync_error_permissions))
|
||||
.setContentText(context.getString(R.string.sync_error_permissions_text))
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(NotificationUtils.NOTIFY_PERMISSIONS, notify)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,42 +5,54 @@
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncResult
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.RemoteException
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.exception.*
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.Property
|
||||
import at.bitfire.dav4android.XmlUtils
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
import at.bitfire.dav4android.exception.ServiceUnavailableException
|
||||
import at.bitfire.dav4android.exception.UnauthorizedException
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.DavService
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalCollection
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.model.SyncState
|
||||
import at.bitfire.davdroid.resource.*
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.ui.AccountSettingsActivity
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.ical4android.CalendarStorageException
|
||||
import at.bitfire.ical4android.MiscUtils
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import java.io.Closeable
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.security.cert.CertificateException
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
|
||||
abstract class SyncManager(
|
||||
abstract class SyncManager<out ResourceType: LocalResource<*>, out CollectionType: LocalCollection<ResourceType>>(
|
||||
val context: Context,
|
||||
val settings: ISettings,
|
||||
val account: Account,
|
||||
@@ -48,423 +60,468 @@ abstract class SyncManager(
|
||||
val extras: Bundle,
|
||||
val authority: String,
|
||||
val syncResult: SyncResult,
|
||||
val uniqueCollectionId: String
|
||||
): Closeable {
|
||||
val localCollection: CollectionType
|
||||
): AutoCloseable {
|
||||
|
||||
companion object {
|
||||
|
||||
val SYNC_PHASE_PREPARE = 0
|
||||
val SYNC_PHASE_QUERY_CAPABILITIES = 1
|
||||
val SYNC_PHASE_PROCESS_LOCALLY_DELETED = 2
|
||||
val SYNC_PHASE_PREPARE_DIRTY = 3
|
||||
val SYNC_PHASE_UPLOAD_DIRTY = 4
|
||||
val SYNC_PHASE_CHECK_SYNC_STATE = 5
|
||||
val SYNC_PHASE_LIST_LOCAL = 6
|
||||
val SYNC_PHASE_LIST_REMOTE = 7
|
||||
val SYNC_PHASE_COMPARE_LOCAL_REMOTE = 8
|
||||
val SYNC_PHASE_DOWNLOAD_REMOTE = 9
|
||||
val SYNC_PHASE_POST_PROCESSING = 10
|
||||
val SYNC_PHASE_SAVE_SYNC_STATE = 11
|
||||
fun cancelNotifications(manager: NotificationManagerCompat, authority: String, account: Account) =
|
||||
manager.cancel(notificationTag(authority, account), NotificationUtils.NOTIFY_SYNC_ERROR)
|
||||
|
||||
infix fun <T> Set<T>.disjunct(other: Set<T>) = (this - other) union (other - this)
|
||||
private fun notificationTag(authority: String, account: Account) =
|
||||
"$authority-${account.name}".hashCode().toString()
|
||||
|
||||
}
|
||||
|
||||
protected val notificationManager = NotificationUtils.createChannels(context)
|
||||
private val mainAccount = if (localCollection is LocalAddressBook)
|
||||
localCollection.mainAccount
|
||||
else
|
||||
account
|
||||
|
||||
protected lateinit var localCollection: LocalCollection<*>
|
||||
protected val notificationManager = NotificationManagerCompat.from(context)
|
||||
protected val notificationTag = notificationTag(authority, mainAccount)
|
||||
|
||||
protected val httpClient = HttpClient.Builder(context, settings, accountSettings).build()
|
||||
protected lateinit var collectionURL: HttpUrl
|
||||
protected lateinit var davCollection: DavResource
|
||||
/** Local resource we're currently operating on. Used for error notifications. **/
|
||||
protected val currentLocalResource = LinkedList<LocalResource<*>>()
|
||||
/** Remote resource we're currently operating on. Used for error notifications. **/
|
||||
protected val currentRemoteResource = LinkedList<HttpUrl>()
|
||||
|
||||
|
||||
/** state information for debug info (local resource) */
|
||||
protected var currentLocalResource: LocalResource? = null
|
||||
|
||||
/** state information for debug info (remote resource) */
|
||||
protected var currentDavResource: DavResource? = null
|
||||
|
||||
|
||||
/** remote CTag at the time of {@link #listRemote()} */
|
||||
protected var remoteCTag: String? = null
|
||||
|
||||
/** sync-able resources in the local collection, as enumerated by {@link #listLocal()} */
|
||||
protected lateinit var localResources: MutableMap<String, LocalResource>
|
||||
|
||||
/** sync-able resources in the remote collection, as enumerated by {@link #listRemote()} */
|
||||
protected lateinit var remoteResources: MutableMap<String, DavResource>
|
||||
|
||||
/** resources which have changed on the server, as determined by {@link #compareLocalRemote()} */
|
||||
protected val toDownload = mutableSetOf<DavResource>()
|
||||
|
||||
|
||||
protected abstract fun notificationId(): Int
|
||||
protected abstract fun getSyncErrorTitle(): String
|
||||
|
||||
@Suppress("UNUSED_VALUE")
|
||||
fun performSync() {
|
||||
// dismiss previous error notifications
|
||||
notificationManager.cancel(uniqueCollectionId, notificationId())
|
||||
notificationManager.cancel(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR)
|
||||
|
||||
var syncPhase = SYNC_PHASE_PREPARE
|
||||
try {
|
||||
Logger.log.info("Preparing synchronization")
|
||||
if (!prepare()) {
|
||||
Logger.log.info("No reason to synchronize, aborting")
|
||||
return
|
||||
}
|
||||
abortIfCancelled()
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_QUERY_CAPABILITIES
|
||||
Logger.log.info("Querying capabilities")
|
||||
queryCapabilities()
|
||||
Logger.log.info("Querying server capabilities")
|
||||
var remoteSyncState = queryCapabilities()
|
||||
abortIfCancelled()
|
||||
|
||||
syncPhase = SYNC_PHASE_PROCESS_LOCALLY_DELETED
|
||||
Logger.log.info("Processing locally deleted entries")
|
||||
processLocallyDeleted()
|
||||
Logger.log.info("Sending local deletes/updates to server")
|
||||
val modificationsSent =
|
||||
processLocallyDeleted() ||
|
||||
uploadDirty()
|
||||
abortIfCancelled()
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_PREPARE_DIRTY
|
||||
Logger.log.info("Locally preparing dirty entries")
|
||||
prepareDirty()
|
||||
if (modificationsSent || syncRequired(remoteSyncState))
|
||||
when (syncAlgorithm()) {
|
||||
SyncAlgorithm.PROPFIND_REPORT -> {
|
||||
Logger.log.info("Sync algorithm: full listing as one result (PROPFIND/REPORT)")
|
||||
resetPresentRemotely()
|
||||
|
||||
syncPhase = SYNC_PHASE_UPLOAD_DIRTY
|
||||
Logger.log.info("Uploading dirty entries")
|
||||
uploadDirty()
|
||||
// get current sync state
|
||||
if (modificationsSent)
|
||||
remoteSyncState = querySyncState()
|
||||
|
||||
syncPhase = SYNC_PHASE_CHECK_SYNC_STATE
|
||||
Logger.log.info("Checking sync state")
|
||||
if (checkSyncState()) {
|
||||
syncPhase = SYNC_PHASE_LIST_LOCAL
|
||||
Logger.log.info("Listing local resources")
|
||||
listLocal()
|
||||
// list all entries at current sync state (which may be the same as or newer than remoteSyncState)
|
||||
Logger.log.info("Listing remote entries")
|
||||
val remote = listAllRemote()
|
||||
abortIfCancelled()
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_LIST_REMOTE
|
||||
Logger.log.info("Listing remote resources")
|
||||
listRemote()
|
||||
Logger.log.info("Comparing local/remote entries")
|
||||
val changes = compareLocalRemote(remote)
|
||||
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
syncPhase = SYNC_PHASE_COMPARE_LOCAL_REMOTE
|
||||
Logger.log.info("Comparing local/remote entries")
|
||||
compareLocalRemote()
|
||||
Logger.log.info("Processing remote changes")
|
||||
processRemoteChanges(changes)
|
||||
|
||||
syncPhase = SYNC_PHASE_DOWNLOAD_REMOTE
|
||||
Logger.log.info("Downloading remote entries")
|
||||
downloadRemote()
|
||||
Logger.log.info("Deleting entries which are not present remotely anymore")
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
syncPhase = SYNC_PHASE_POST_PROCESSING
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
|
||||
syncPhase = SYNC_PHASE_SAVE_SYNC_STATE
|
||||
Logger.log.info("Saving sync state")
|
||||
saveSyncState()
|
||||
} else
|
||||
Logger.log.info("Remote collection didn't change, skipping remote sync")
|
||||
Logger.log.log(Level.INFO, "Saving sync state", remoteSyncState)
|
||||
localCollection.lastSyncState = remoteSyncState
|
||||
}
|
||||
SyncAlgorithm.COLLECTION_SYNC -> {
|
||||
var initialSync = false
|
||||
|
||||
} catch(e: IOException) {
|
||||
Logger.log.log(Level.WARNING, "I/O exception during sync, trying again later", e)
|
||||
syncResult.stats.numIoExceptions++
|
||||
} catch(e: ServiceUnavailableException) {
|
||||
var syncState = localCollection.lastSyncState?.takeIf { it.type == SyncState.Type.SYNC_TOKEN }
|
||||
|
||||
Logger.log.info("Listing changes since $syncState")
|
||||
var changes: RemoteChanges? = try {
|
||||
listRemoteChanges(syncState)
|
||||
} catch(e: HttpException) {
|
||||
if (e.errors.contains(Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token"))) {
|
||||
Logger.log.info("Sync token stale, retrying without sync-token")
|
||||
syncState = null
|
||||
listRemoteChanges(null)
|
||||
} else
|
||||
throw e
|
||||
}
|
||||
|
||||
if (syncState == null) {
|
||||
Logger.log.info("Starting initial sync")
|
||||
initialSync = true
|
||||
resetPresentRemotely()
|
||||
}
|
||||
if (syncState?.initialSync == true) {
|
||||
Logger.log.info("Continuing initial sync")
|
||||
initialSync = true
|
||||
}
|
||||
|
||||
while (changes != null) {
|
||||
Logger.log.info("Processing received changes")
|
||||
processRemoteChanges(changes)
|
||||
|
||||
// save sync state and keep whether we're in initial sync
|
||||
syncState = changes.state ?: throw DavException("Received sync-collection without sync-token")
|
||||
syncState.initialSync = initialSync
|
||||
Logger.log.log(Level.INFO, "Saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
|
||||
// request next bunch of changes (if available), or exit loop
|
||||
changes = if (changes.furtherChanges)
|
||||
listRemoteChanges(syncState)
|
||||
else {
|
||||
Logger.log.info("No more changes available on server")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (initialSync) {
|
||||
// initial sync is finished, remove all local resources which have
|
||||
// not been sent by the server
|
||||
Logger.log.info("Deleting local resources which are not on server (anymore)")
|
||||
deleteNotPresentRemotely()
|
||||
|
||||
// remove initial sync flag
|
||||
syncState!!.initialSync = false
|
||||
Logger.log.log(Level.INFO, "Initial sync completed, saving sync state", syncState)
|
||||
localCollection.lastSyncState = syncState
|
||||
}
|
||||
|
||||
Logger.log.info("Post-processing")
|
||||
postProcess()
|
||||
}
|
||||
}
|
||||
else
|
||||
Logger.log.info("Remote collection didn't change, no reason to sync")
|
||||
|
||||
}
|
||||
// sync was cancelled: re-throw to SyncAdapterService
|
||||
catch (e: InterruptedException) { throw e }
|
||||
catch (e: InterruptedIOException) { throw e }
|
||||
|
||||
// specific I/O errors
|
||||
catch (e: SSLHandshakeException) {
|
||||
Logger.log.log(Level.WARNING, "SSL handshake failed", e)
|
||||
|
||||
// when a certificate is rejected by cert4android, the cause will be a CertificateException
|
||||
if (!BuildConfig.customCerts || e.cause !is CertificateException)
|
||||
notifyException(e)
|
||||
}
|
||||
|
||||
// specific HTTP errors
|
||||
catch (e: ServiceUnavailableException) {
|
||||
Logger.log.log(Level.WARNING, "Got 503 Service unavailable, trying again later", e)
|
||||
syncResult.stats.numIoExceptions++
|
||||
e.retryAfter?.let { retryAfter ->
|
||||
// how many seconds to wait? getTime() returns ms, so divide by 1000
|
||||
syncResult.delayUntil = (retryAfter.time - Date().time) / 1000
|
||||
}
|
||||
} catch(e: Throwable) {
|
||||
val messageString: Int
|
||||
|
||||
when (e) {
|
||||
is UnauthorizedException -> {
|
||||
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
|
||||
messageString = R.string.sync_error_unauthorized
|
||||
syncResult.stats.numAuthExceptions++
|
||||
}
|
||||
is HttpException, is DavException -> {
|
||||
Logger.log.log(Level.SEVERE, "HTTP/DAV Exception during sync", e)
|
||||
messageString = R.string.sync_error_http_dav
|
||||
syncResult.stats.numParseExceptions++
|
||||
}
|
||||
is CalendarStorageException, is ContactsStorageException -> {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
|
||||
messageString = R.string.sync_error_local_storage
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
else -> {
|
||||
Logger.log.log(Level.SEVERE, "Unknown sync error", e)
|
||||
messageString = R.string.sync_error
|
||||
syncResult.stats.numParseExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
val detailsIntent: Intent
|
||||
if (e is UnauthorizedException) {
|
||||
detailsIntent = Intent(context, AccountSettingsActivity::class.java)
|
||||
detailsIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
} else {
|
||||
detailsIntent = Intent(context, DebugInfoActivity::class.java)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
|
||||
detailsIntent.putExtra(DebugInfoActivity.KEY_PHASE, syncPhase)
|
||||
currentLocalResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, it.toString()) }
|
||||
currentDavResource?.let { detailsIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, it.toString()) }
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
detailsIntent.data = Uri.parse("uri://${javaClass.name}/$uniqueCollectionId")
|
||||
|
||||
val builder = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_SYNC_PROBLEMS)
|
||||
builder .setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setLargeIcon(App.getLauncherBitmap(context))
|
||||
.setContentTitle(getSyncErrorTitle())
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, detailsIntent, PendingIntent.FLAG_CANCEL_CURRENT))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
|
||||
try {
|
||||
val phases = context.resources.getStringArray(R.array.sync_error_phases)
|
||||
val message = context.getString(messageString, phases[syncPhase])
|
||||
builder.setContentText(message)
|
||||
} catch (ex: IndexOutOfBoundsException) {
|
||||
// should never happen
|
||||
}
|
||||
|
||||
notificationManager.notify(uniqueCollectionId, notificationId(), builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
httpClient.close()
|
||||
// all others
|
||||
catch (e: Throwable) { notifyException(e) }
|
||||
}
|
||||
|
||||
|
||||
/** Prepares synchronization (for instance, allocates necessary resources).
|
||||
* @return whether actual synchronization is required / can be made. true = synchronization
|
||||
* shall be continued, false = synchronization can be skipped */
|
||||
abstract protected fun prepare(): Boolean
|
||||
|
||||
abstract protected fun queryCapabilities()
|
||||
protected abstract fun prepare(): Boolean
|
||||
|
||||
/**
|
||||
* Process locally deleted entries (DELETE them on the server as well).
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
* Queries the server for synchronization capabilities like specific report types,
|
||||
* data formats etc.
|
||||
*
|
||||
* Should also query and save the initial sync state (e.g. CTag/sync-token).
|
||||
*
|
||||
* @return current sync state
|
||||
*/
|
||||
protected open fun processLocallyDeleted() {
|
||||
// Remove locally deleted entries from server (if they have a name, i.e. if they were uploaded before),
|
||||
// but only if they don't have changed on the server. Then finally remove them from the local address book.
|
||||
val localList = localCollection.getDeleted()
|
||||
for (local in localList) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
currentLocalResource = local
|
||||
|
||||
val fileName = local.fileName
|
||||
if (fileName != null) {
|
||||
Logger.log.info("$fileName has been deleted locally -> deleting from server")
|
||||
|
||||
val remote = DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
|
||||
currentDavResource = remote
|
||||
try {
|
||||
remote.delete(local.eTag)
|
||||
} catch (e: HttpException) {
|
||||
Logger.log.warning("Couldn't delete $fileName from server; ignoring (may be downloaded again)")
|
||||
}
|
||||
} else
|
||||
Logger.log.info("Removing local record #${local.id} which has been deleted locally and was never uploaded")
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
|
||||
currentLocalResource = null
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun prepareDirty() {
|
||||
// assign file names and UIDs to new contacts so that we can use the file name as an index
|
||||
Logger.log.info("Looking for contacts/groups without file name")
|
||||
for (local in localCollection.getWithoutFileName()) {
|
||||
currentLocalResource = local
|
||||
|
||||
Logger.log.fine("Found local record #${local.id} without file name; generating file name/UID if necessary")
|
||||
local.prepareForUpload()
|
||||
|
||||
currentLocalResource = null
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected fun prepareUpload(resource: LocalResource): RequestBody
|
||||
protected abstract fun queryCapabilities(): SyncState?
|
||||
|
||||
/**
|
||||
* Uploads dirty records to the server, using a PUT request for each record.
|
||||
* Checks Thread.interrupted() before each request to allow quick sync cancellation.
|
||||
* Queries the remote sync state of the collection.
|
||||
*
|
||||
* @return sync state (may be null)
|
||||
*/
|
||||
protected open fun uploadDirty() {
|
||||
// upload dirty contacts
|
||||
for (local in localCollection.getDirty()) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
currentLocalResource = local
|
||||
val fileName = local.fileName
|
||||
|
||||
val remote = DavResource(httpClient.okHttpClient, collectionURL.newBuilder().addPathSegment(fileName).build())
|
||||
currentDavResource = remote
|
||||
|
||||
// generate entity to upload (VCard, iCal, whatever)
|
||||
val body = prepareUpload(local)
|
||||
|
||||
try {
|
||||
if (local.eTag == null) {
|
||||
Logger.log.info("Uploading new record $fileName")
|
||||
remote.put(body, null, true)
|
||||
} else {
|
||||
Logger.log.info("Uploading locally modified record $fileName")
|
||||
remote.put(body, local.eTag, false)
|
||||
}
|
||||
} catch(e: ConflictException) {
|
||||
// we can't interact with the user to resolve the conflict, so we treat 409 like 412
|
||||
Logger.log.log(Level.INFO, "Edit conflict, ignoring", e)
|
||||
} catch(e: PreconditionFailedException) {
|
||||
Logger.log.log(Level.INFO, "Resource has been modified on the server before upload, ignoring", e)
|
||||
}
|
||||
|
||||
val newETag = remote.properties[GetETag.NAME] as GetETag?
|
||||
val eTag: String?
|
||||
if (newETag != null) {
|
||||
eTag = newETag.eTag
|
||||
Logger.log.fine("Received new ETag=$eTag after uploading")
|
||||
} else {
|
||||
Logger.log.fine("Didn't receive new ETag after uploading, setting to null")
|
||||
eTag = null
|
||||
}
|
||||
|
||||
local.clearDirty(eTag)
|
||||
|
||||
currentLocalResource = null
|
||||
currentDavResource = null
|
||||
}
|
||||
}
|
||||
protected abstract fun querySyncState(): SyncState?
|
||||
|
||||
/**
|
||||
* Checks the current sync state (e.g. CTag) and whether synchronization from remote is required.
|
||||
* @return <ul>
|
||||
* <li><code>true</code> if the remote collection has changed, i.e. synchronization from remote is required</li>
|
||||
* <li><code>false</code> if the remote collection hasn't changed</li>
|
||||
* </ul>
|
||||
* Forwards local deletions to the server.
|
||||
*
|
||||
* @return whether remote resources have been deleted
|
||||
*/
|
||||
protected open fun checkSyncState(): Boolean {
|
||||
// check CTag (ignore on manual sync)
|
||||
(davCollection.properties[GetCTag.NAME] as GetCTag?)?.let { remoteCTag = it.cTag }
|
||||
protected abstract fun processLocallyDeleted(): Boolean
|
||||
|
||||
val localCTag = if (extras.containsKey(ContentResolver.SYNC_EXTRAS_MANUAL)) {
|
||||
Logger.log.info("Manual sync, ignoring CTag")
|
||||
/**
|
||||
* Uploads locally modified resources to the server.
|
||||
*
|
||||
* @return whether resources have been uploaded
|
||||
*/
|
||||
protected abstract fun uploadDirty(): Boolean
|
||||
|
||||
/**
|
||||
* Determines whether a sync is required because there were changes on the server.
|
||||
* For instance, this method can compare the collection's CTag/sync-token with
|
||||
* the last known local value.
|
||||
*
|
||||
* When local changes have been uploaded ([processLocallyDeleted] and/or
|
||||
* [uploadDirty] were true), a sync is always required and this method
|
||||
* should not be evaluated.
|
||||
*
|
||||
* @param state remote sync state to compare local sync state with
|
||||
*
|
||||
* @return whether data has been changed on the server = whether running the
|
||||
* sync algorithm is required
|
||||
*/
|
||||
protected abstract fun syncRequired(state: SyncState?): Boolean
|
||||
|
||||
/**
|
||||
* Determines which sync algorithm to use.
|
||||
* @return
|
||||
* - [SyncAlgorithm.PROPFIND_REPORT]: list all resources (with plain WebDAV
|
||||
* PROPFIND or specific REPORT requests), then compare and synchronize
|
||||
* - [SyncAlgorithm.COLLECTION_SYNC]: use incremental collection synchronization (RFC 6578)
|
||||
*/
|
||||
protected abstract fun syncAlgorithm(): SyncAlgorithm
|
||||
|
||||
/**
|
||||
* Marks all local resources which shall be taken into consideration for this
|
||||
* sync as "synchronizing". Purpose of marking is that resources which have been marked
|
||||
* and are not present remotely anymore can be deleted.
|
||||
*
|
||||
* Used together with [deleteNotPresentRemotely].
|
||||
*/
|
||||
protected abstract fun resetPresentRemotely()
|
||||
|
||||
/**
|
||||
* Lists all remote resources which should be taken into account for synchronization.
|
||||
* Will be used if incremental synchronization is not available.
|
||||
*
|
||||
* @return map with resource names (like "mycontact.vcf") as keys and the resources
|
||||
*/
|
||||
protected abstract fun listAllRemote(): Map<String, DavResponse>
|
||||
|
||||
|
||||
/**
|
||||
* Compares local resources which are marked for synchronization and remote resources by file name and ETag.
|
||||
* Remote resources
|
||||
* + which are not present locally
|
||||
* + whose ETag has changed since the last sync (i.e. remote ETag != locally known last remote ETag)
|
||||
* will be saved as "updated" in the result.
|
||||
*
|
||||
* Must mark all found remote resources as "present remotely", so that a later execution of
|
||||
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
|
||||
*
|
||||
* @param remoteResources map of remote resource names and resources
|
||||
*
|
||||
* @return List of updated resources on the server. The "deleted" list remains empty. Sync
|
||||
* state will be null.
|
||||
*/
|
||||
protected abstract fun compareLocalRemote(remoteResources: Map<String, DavResponse>): RemoteChanges
|
||||
|
||||
/**
|
||||
* Lists remote changes (incremental sync).
|
||||
*
|
||||
* Must mark all found remote resources as "present remotely", so that a later execution of
|
||||
* [deleteNotPresentRemotely] doesn't (locally) delete any currently available remote resources.
|
||||
*
|
||||
* @return list of of remote changes together with the sync state after those changes
|
||||
*/
|
||||
protected abstract fun listRemoteChanges(state: SyncState?): RemoteChanges
|
||||
|
||||
/**
|
||||
* Processes remote changes:
|
||||
* + downloads and locally saves remotely updated resources
|
||||
* + locally deletes remotely deleted resources
|
||||
*
|
||||
* Should call [abortIfCancelled] from time to time, for instance
|
||||
* after downloading a resource.
|
||||
*
|
||||
* Must mark downloaded resources as present on server.
|
||||
*
|
||||
* @param changes list of remotely updated and deleted resources
|
||||
*/
|
||||
protected abstract fun processRemoteChanges(changes: RemoteChanges)
|
||||
|
||||
/**
|
||||
* Locally deletes entries which are
|
||||
* 1. not dirty and
|
||||
* 2. not marked as [LocalResource.FLAG_REMOTELY_PRESENT].
|
||||
*
|
||||
* Used together with [resetPresentRemotely] when a full listing has been received from
|
||||
* the server to locally delete resources which are not present remotely (anymore).
|
||||
*/
|
||||
protected abstract fun deleteNotPresentRemotely()
|
||||
|
||||
/**
|
||||
* Post-processing of synchronized entries, for instance contact group membership operations.
|
||||
*/
|
||||
protected abstract fun postProcess()
|
||||
|
||||
|
||||
/**
|
||||
* Throws an [InterruptedException] if the current thread has been interrupted,
|
||||
* most probably because synchronization was cancelled by the user.
|
||||
*
|
||||
* @throws InterruptedException (which will be caught by [performSync])
|
||||
* */
|
||||
protected fun abortIfCancelled() {
|
||||
if (Thread.interrupted())
|
||||
throw InterruptedException("Sync was cancelled")
|
||||
}
|
||||
|
||||
private fun notifyException(e: Throwable) {
|
||||
val message: String
|
||||
|
||||
when (e) {
|
||||
is IOException,
|
||||
is InterruptedIOException -> {
|
||||
Logger.log.log(Level.WARNING, "I/O error", e)
|
||||
message = context.getString(R.string.sync_error_io, e.localizedMessage)
|
||||
syncResult.stats.numIoExceptions++
|
||||
}
|
||||
is UnauthorizedException -> {
|
||||
Logger.log.log(Level.SEVERE, "Not authorized anymore", e)
|
||||
message = context.getString(R.string.sync_error_authentication_failed)
|
||||
syncResult.stats.numAuthExceptions++
|
||||
}
|
||||
is HttpException, is DavException -> {
|
||||
Logger.log.log(Level.SEVERE, "HTTP/DAV exception", e)
|
||||
message = context.getString(R.string.sync_error_http_dav, e.localizedMessage)
|
||||
syncResult.stats.numParseExceptions++ // numIoExceptions would indicate a soft error
|
||||
}
|
||||
is CalendarStorageException, is ContactsStorageException, is RemoteException -> {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't access local storage", e)
|
||||
message = context.getString(R.string.sync_error_local_storage, e.localizedMessage)
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
else -> {
|
||||
Logger.log.log(Level.SEVERE, "Unclassified sync error", e)
|
||||
message = e.localizedMessage ?: e::class.java.simpleName
|
||||
syncResult.stats.numParseExceptions++
|
||||
}
|
||||
}
|
||||
|
||||
val contentIntent: Intent
|
||||
var viewItemAction: NotificationCompat.Action? = null
|
||||
if (e is UnauthorizedException) {
|
||||
contentIntent = Intent(context, AccountSettingsActivity::class.java)
|
||||
contentIntent.putExtra(AccountSettingsActivity.EXTRA_ACCOUNT, account)
|
||||
} else {
|
||||
contentIntent = Intent(context, DebugInfoActivity::class.java)
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_THROWABLE, e)
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_ACCOUNT, account)
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_AUTHORITY, authority)
|
||||
|
||||
// use current local/remote resource
|
||||
currentLocalResource.firstOrNull()?.let { local ->
|
||||
// pass local resource info to debug info
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_LOCAL_RESOURCE, local.toString())
|
||||
|
||||
// generate "view item" action
|
||||
viewItemAction = buildViewItemAction(local)
|
||||
}
|
||||
currentRemoteResource.firstOrNull()?.let { remote ->
|
||||
contentIntent.putExtra(DebugInfoActivity.KEY_REMOTE_RESOURCE, remote.toString())
|
||||
}
|
||||
}
|
||||
|
||||
// to make the PendingIntent unique
|
||||
contentIntent.data = Uri.parse("davdroid:exception/${e.hashCode()}")
|
||||
|
||||
val channel: String
|
||||
val priority: Int
|
||||
if (e is IOException) {
|
||||
channel = NotificationUtils.CHANNEL_SYNC_IO_ERRORS
|
||||
priority = NotificationCompat.PRIORITY_MIN
|
||||
} else {
|
||||
channel = NotificationUtils.CHANNEL_SYNC_ERRORS
|
||||
priority = NotificationCompat.PRIORITY_DEFAULT
|
||||
}
|
||||
|
||||
val builder = NotificationUtils.newBuilder(context, channel)
|
||||
builder .setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setContentTitle(localCollection.title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle(builder).bigText(message))
|
||||
.setSubText(mainAccount.name)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setPriority(priority)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
viewItemAction?.let { builder.addAction(it) }
|
||||
builder.addAction(buildRetryAction())
|
||||
|
||||
notificationManager.notify(notificationTag, NotificationUtils.NOTIFY_SYNC_ERROR, builder.build())
|
||||
}
|
||||
|
||||
private fun buildRetryAction(): NotificationCompat.Action {
|
||||
val retryIntent = Intent(context, DavService::class.java)
|
||||
retryIntent.action = DavService.ACTION_FORCE_SYNC
|
||||
|
||||
val syncAuthority: String
|
||||
val syncAccount: Account
|
||||
if (authority == ContactsContract.AUTHORITY) {
|
||||
// if this is a contacts sync, retry syncing all address books of the main account
|
||||
syncAuthority = context.getString(R.string.address_books_authority)
|
||||
syncAccount = mainAccount
|
||||
} else {
|
||||
syncAuthority = authority
|
||||
syncAccount = account
|
||||
}
|
||||
|
||||
retryIntent.data = Uri.parse("sync://").buildUpon()
|
||||
.authority(syncAuthority)
|
||||
.appendPath(syncAccount.type)
|
||||
.appendPath(syncAccount.name)
|
||||
.build()
|
||||
|
||||
return NotificationCompat.Action(
|
||||
android.R.drawable.ic_menu_rotate, context.getString(R.string.sync_error_retry),
|
||||
PendingIntent.getService(context, 0, retryIntent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
}
|
||||
|
||||
private fun buildViewItemAction(local: LocalResource<*>): NotificationCompat.Action? {
|
||||
Logger.log.log(Level.FINE, "Adding view action for local resource", local)
|
||||
val intent = local.id?.let { id ->
|
||||
when (local) {
|
||||
is LocalContact ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.RawContacts.CONTENT_URI, id))
|
||||
is LocalEvent ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, id))
|
||||
is LocalTask ->
|
||||
Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(TaskContract.Tasks.getContentUri(TaskProvider.ProviderName.OpenTasks.authority), id))
|
||||
else ->
|
||||
null
|
||||
}
|
||||
}
|
||||
return if (intent != null && context.packageManager.resolveActivity(intent, 0) != null)
|
||||
NotificationCompat.Action(android.R.drawable.ic_menu_view, context.getString(R.string.sync_error_view_item),
|
||||
PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
else
|
||||
null
|
||||
} else
|
||||
localCollection.getCTag()
|
||||
|
||||
return if (remoteCTag != null && remoteCTag == localCTag) {
|
||||
Logger.log.info("Remote collection didn't change (CTag=$remoteCTag), no need to query children")
|
||||
false
|
||||
} else
|
||||
true
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all local resources which should be taken into account for synchronization into {@link #localResources}.
|
||||
*/
|
||||
protected open fun listLocal() {
|
||||
// fetch list of local contacts and build hash table to index file name
|
||||
val localList = localCollection.getAll()
|
||||
val resources = HashMap<String, LocalResource>(localList.size)
|
||||
for (resource in localList) {
|
||||
Logger.log.fine("Found local resource: ${resource.fileName}")
|
||||
resource.fileName?.let { resources[it] = resource }
|
||||
}
|
||||
localResources = resources
|
||||
Logger.log.info("Found ${localResources.size} local resources")
|
||||
|
||||
enum class SyncAlgorithm {
|
||||
PROPFIND_REPORT,
|
||||
COLLECTION_SYNC
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all members of the remote collection which should be taken into account for synchronization into {@link #remoteResources}.
|
||||
*/
|
||||
abstract protected fun listRemote()
|
||||
class RemoteChanges(
|
||||
val state: SyncState?,
|
||||
val furtherChanges: Boolean
|
||||
) {
|
||||
val deleted = LinkedList<String>()
|
||||
val updated = LinkedList<DavResponse>()
|
||||
|
||||
/**
|
||||
* Compares {@link #localResources} and {@link #remoteResources} by file name and ETag:
|
||||
* <ul>
|
||||
* <li>Local resources which are not available in the remote collection (anymore) will be removed.</li>
|
||||
* <li>Resources whose remote ETag has changed will be added into {@link #toDownload}</li>
|
||||
* </ul>
|
||||
*/
|
||||
protected open fun compareLocalRemote() {
|
||||
/* check which contacts
|
||||
1. are not present anymore remotely -> delete immediately on local side
|
||||
2. updated remotely -> add to downloadNames
|
||||
3. added remotely -> add to downloadNames
|
||||
*/
|
||||
toDownload.clear()
|
||||
for ((name,local) in localResources) {
|
||||
val remote = remoteResources[name]
|
||||
currentDavResource = remote
|
||||
|
||||
if (remote == null) {
|
||||
Logger.log.info("$name is not on server anymore, deleting")
|
||||
currentLocalResource = local
|
||||
local.delete()
|
||||
syncResult.stats.numDeletes++
|
||||
} else {
|
||||
// contact is still on server, check whether it has been updated remotely
|
||||
val localETag = local.eTag
|
||||
val getETag = remote.properties[GetETag.NAME] as GetETag?
|
||||
val remoteETag = getETag?.eTag ?: throw DavException("Server didn't provide ETag")
|
||||
if (remoteETag == localETag) {
|
||||
Logger.log.fine("$name has not been changed on server (ETag still $remoteETag)")
|
||||
syncResult.stats.numSkippedEntries++
|
||||
} else {
|
||||
Logger.log.info("$name has been changed on server (current ETag=$remoteETag, last known ETag=$localETag)")
|
||||
toDownload.add(remote)
|
||||
}
|
||||
|
||||
// remote entry has been seen, remove from list
|
||||
remoteResources.remove(name)
|
||||
|
||||
currentDavResource = null
|
||||
currentLocalResource = null
|
||||
}
|
||||
}
|
||||
|
||||
// add all unseen (= remotely added) remote contacts
|
||||
if (remoteResources.isNotEmpty()) {
|
||||
Logger.log.info("New resources have been found on the server: ${remoteResources.keys.joinToString(", ")}")
|
||||
toDownload.addAll(remoteResources.values)
|
||||
}
|
||||
override fun toString() = MiscUtils.reflectionToString(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the remote resources in {@link #toDownload} and stores them locally.
|
||||
* Must check Thread.interrupted() periodically to allow quick sync cancellation.
|
||||
*/
|
||||
abstract protected fun downloadRemote()
|
||||
|
||||
/**
|
||||
* For post-processing of entries, for instance assigning groups.
|
||||
*/
|
||||
protected open fun postProcess() {}
|
||||
|
||||
protected open fun saveSyncState() {
|
||||
/* Save sync state (CTag). It doesn't matter if it has changed during the sync process
|
||||
(for instance, because another client has uploaded changes), because this will simply
|
||||
cause all remote entries to be listed at the next sync. */
|
||||
Logger.log.info("Saving CTag=$remoteCTag")
|
||||
localCollection.setCTag(remoteCTag)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,17 @@
|
||||
package at.bitfire.davdroid.syncadapter
|
||||
|
||||
import android.accounts.Account
|
||||
import android.app.PendingIntent
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.DatabaseUtils
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import android.support.v4.app.NotificationManagerCompat
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
@@ -19,9 +26,11 @@ import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.model.ServiceDB.Services
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.ui.NotificationUtils
|
||||
import at.bitfire.ical4android.AndroidTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import org.dmfs.provider.tasks.TaskContract
|
||||
import okhttp3.HttpUrl
|
||||
import org.dmfs.tasks.contract.TaskContract
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
@@ -38,7 +47,7 @@ class TasksSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
override fun sync(settings: ISettings, account: Account, extras: Bundle, authority: String, provider: ContentProviderClient, syncResult: SyncResult) {
|
||||
try {
|
||||
val taskProvider = TaskProvider.fromProviderClient(provider)
|
||||
val taskProvider = TaskProvider.fromProviderClient(context, provider)
|
||||
val accountSettings = AccountSettings(context, settings, account)
|
||||
/* don't run sync if
|
||||
- sync conditions (e.g. "sync only in WiFi") are not met AND
|
||||
@@ -51,12 +60,36 @@ class TasksSyncAdapterService: SyncAdapterService() {
|
||||
|
||||
for (taskList in AndroidTaskList.find(account, taskProvider, LocalTaskList.Factory, "${TaskContract.TaskLists.SYNC_ENABLED}!=0", null)) {
|
||||
Logger.log.info("Synchronizing task list #${taskList.id} [${taskList.syncId}]")
|
||||
TasksSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, taskProvider, taskList).use {
|
||||
TasksSyncManager(context, settings, account, accountSettings, extras, authority, syncResult, taskList).use {
|
||||
it.performSync()
|
||||
}
|
||||
}
|
||||
} catch (e: TaskProvider.ProviderTooOldException) {
|
||||
val nm = NotificationManagerCompat.from(context)
|
||||
val message = context.getString(R.string.sync_error_opentasks_required_version, e.provider.minVersionName, e.installedVersionName)
|
||||
val notify = NotificationUtils.newBuilder(context)
|
||||
.setSmallIcon(R.drawable.ic_sync_error_notification)
|
||||
.setContentTitle(context.getString(R.string.sync_error_opentasks_too_old))
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
|
||||
try {
|
||||
val icon = context.packageManager.getApplicationIcon(e.provider.packageName)
|
||||
if (icon is BitmapDrawable)
|
||||
notify.setLargeIcon(icon.bitmap)
|
||||
} catch(ignored: PackageManager.NameNotFoundException) {}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${e.provider.packageName}"))
|
||||
if (intent.resolveActivity(context.packageManager) != null)
|
||||
notify .setContentIntent(PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||
.setAutoCancel(true)
|
||||
|
||||
nm.notify(NotificationUtils.NOTIFY_OPENTASKS, notify.build())
|
||||
syncResult.databaseError = true
|
||||
} catch (e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't sync task lists", e)
|
||||
syncResult.databaseError = true
|
||||
}
|
||||
|
||||
Logger.log.info("Task sync complete")
|
||||
@@ -76,8 +109,8 @@ class TasksSyncAdapterService: SyncAdapterService() {
|
||||
null
|
||||
}
|
||||
|
||||
fun remoteTaskLists(service: Long?): MutableMap<String, CollectionInfo> {
|
||||
val collections = mutableMapOf<String, CollectionInfo>()
|
||||
fun remoteTaskLists(service: Long?): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val collections = mutableMapOf<HttpUrl, CollectionInfo>()
|
||||
service?.let {
|
||||
db.query(Collections._TABLE, null,
|
||||
"${Collections.SERVICE_ID}=? AND ${Collections.SUPPORTS_VTODO}!=0 AND ${Collections.SYNC}",
|
||||
@@ -101,7 +134,8 @@ class TasksSyncAdapterService: SyncAdapterService() {
|
||||
val updateColors = settings.getManageCalendarColors()
|
||||
|
||||
for (list in AndroidTaskList.find(account, provider, LocalTaskList.Factory, null, null))
|
||||
list.syncId?.let { url ->
|
||||
list.syncId?.let {
|
||||
val url = HttpUrl.parse(it)!!
|
||||
val info = remote[url]
|
||||
if (info == null) {
|
||||
Logger.log.fine("Deleting obsolete local task list $url")
|
||||
|
||||
@@ -13,13 +13,14 @@ import android.content.Context
|
||||
import android.content.SyncResult
|
||||
import android.os.Bundle
|
||||
import at.bitfire.dav4android.DavCalendar
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.property.CalendarData
|
||||
import at.bitfire.dav4android.property.GetCTag
|
||||
import at.bitfire.dav4android.property.GetETag
|
||||
import at.bitfire.dav4android.property.SyncToken
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalResource
|
||||
import at.bitfire.davdroid.resource.LocalTask
|
||||
@@ -27,16 +28,17 @@ import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.ical4android.InvalidCalendarException
|
||||
import at.bitfire.ical4android.Task
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.RequestBody
|
||||
import org.apache.commons.collections4.ListUtils
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Reader
|
||||
import java.io.StringReader
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
/**
|
||||
* Synchronization manager for CalDAV collections; handles tasks (VTODO)
|
||||
*/
|
||||
class TasksSyncManager(
|
||||
context: Context,
|
||||
settings: ISettings,
|
||||
@@ -45,119 +47,106 @@ class TasksSyncManager(
|
||||
extras: Bundle,
|
||||
authority: String,
|
||||
syncResult: SyncResult,
|
||||
val provider: TaskProvider,
|
||||
val localTaskList: LocalTaskList
|
||||
): SyncManager(context, settings, account, accountSettings, extras, authority, syncResult, "taskList/${localTaskList.id}") {
|
||||
localCollection: LocalTaskList
|
||||
): BaseDavSyncManager<LocalTask, LocalTaskList, DavCalendar>(context, settings, account, accountSettings, extras, authority, syncResult, localCollection) {
|
||||
|
||||
val MAX_MULTIGET = 30
|
||||
|
||||
|
||||
init {
|
||||
localCollection = localTaskList
|
||||
companion object {
|
||||
const val MULTIGET_MAX_RESOURCES = 30
|
||||
}
|
||||
|
||||
override fun notificationId() = Constants.NOTIFICATION_TASK_SYNC
|
||||
|
||||
override fun getSyncErrorTitle() = context.getString(R.string.sync_error_tasks, account.name)!!
|
||||
|
||||
|
||||
override fun prepare(): Boolean {
|
||||
collectionURL = HttpUrl.parse(localTaskList.syncId ?: return false) ?: return false
|
||||
if (!super.prepare())
|
||||
return false
|
||||
|
||||
val url = localCollection.syncId ?: return false
|
||||
collectionURL = HttpUrl.parse(url) ?: return false
|
||||
davCollection = DavCalendar(httpClient.okHttpClient, collectionURL)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryCapabilities() {
|
||||
davCollection.propfind(0, GetCTag.NAME)
|
||||
}
|
||||
|
||||
override fun prepareUpload(resource: LocalResource): RequestBody {
|
||||
if (resource is LocalTask) {
|
||||
val task = requireNotNull(resource.task)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os)
|
||||
|
||||
return RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR,
|
||||
os.toByteArray()
|
||||
)
|
||||
} else
|
||||
throw IllegalArgumentException("resource must be a LocalTask")
|
||||
}
|
||||
|
||||
override fun listRemote() {
|
||||
// fetch list of remote VTODOs and build hash table to index file name
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.calendarQuery("VTODO", null, null)
|
||||
|
||||
remoteResources = HashMap(davCollection.members.size)
|
||||
for (vCard in davCollection.members) {
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VTODO: $fileName")
|
||||
remoteResources[fileName] = vCard
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
}
|
||||
|
||||
override fun downloadRemote() {
|
||||
Logger.log.info("Downloading ${toDownload.size} tasks ($MAX_MULTIGET at once)")
|
||||
|
||||
// download new/updated iCalendars from server
|
||||
for (bunch in ListUtils.partition(toDownload.toList(), MAX_MULTIGET)) {
|
||||
if (Thread.interrupted())
|
||||
return
|
||||
|
||||
Logger.log.info("Downloading ${bunch.joinToString(", ")}")
|
||||
|
||||
if (bunch.size == 1) {
|
||||
// only one contact, use GET
|
||||
val remote = bunch.first()
|
||||
currentDavResource = remote
|
||||
|
||||
val body = remote.get("text/calendar")
|
||||
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = remote.properties[GetETag.NAME] as GetETag?
|
||||
if (eTag == null || eTag.eTag.isNullOrEmpty())
|
||||
throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
body.charStream().use { reader ->
|
||||
processVTodo(remote.fileName(), eTag.eTag!!, reader)
|
||||
}
|
||||
|
||||
} else {
|
||||
// multiple contacts, use multi-get
|
||||
val calendar = davCalendar()
|
||||
currentDavResource = calendar
|
||||
calendar.multiget(bunch.map { it.location })
|
||||
|
||||
// process multiget results
|
||||
for (remote in davCollection.members) {
|
||||
currentDavResource = remote
|
||||
|
||||
val eTag = (remote.properties[GetETag.NAME] as GetETag?)?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote.properties[CalendarData.NAME] as CalendarData?
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
|
||||
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
override fun queryCapabilities() =
|
||||
useRemoteCollection {
|
||||
it.propfind(0, GetCTag.NAME, SyncToken.NAME).use { dav ->
|
||||
syncState(dav)
|
||||
}
|
||||
}
|
||||
|
||||
currentDavResource = null
|
||||
override fun syncAlgorithm() = SyncAlgorithm.PROPFIND_REPORT
|
||||
|
||||
override fun prepareUpload(resource: LocalTask): RequestBody = useLocal(resource, {
|
||||
val task = requireNotNull(resource.task)
|
||||
Logger.log.log(Level.FINE, "Preparing upload of task ${resource.fileName}", task)
|
||||
|
||||
val os = ByteArrayOutputStream()
|
||||
task.write(os)
|
||||
|
||||
RequestBody.create(
|
||||
DavCalendar.MIME_ICALENDAR_UTF8,
|
||||
os.toByteArray()
|
||||
)
|
||||
})
|
||||
|
||||
override fun listAllRemote() = useRemoteCollection { remote ->
|
||||
remote.calendarQuery("VTODO", null, null).use { dav ->
|
||||
val result = LinkedHashMap<String, DavResponse>(dav.members.size)
|
||||
for (vCard in dav.members) {
|
||||
val fileName = vCard.fileName()
|
||||
Logger.log.fine("Found remote VTODO: $fileName")
|
||||
result[fileName] = vCard
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
override fun processRemoteChanges(changes: RemoteChanges) {
|
||||
for (name in changes.deleted) {
|
||||
localCollection.findByName(name)?.let {
|
||||
Logger.log.info("Deleting local task $name")
|
||||
useLocal(it, { it.delete() })
|
||||
syncResult.stats.numDeletes++
|
||||
}
|
||||
}
|
||||
|
||||
// helpers
|
||||
val toDownload = changes.updated.map { it.url }
|
||||
Logger.log.info("Downloading ${toDownload.size} resources ($MULTIGET_MAX_RESOURCES at once)")
|
||||
|
||||
private fun davCalendar() = davCollection as DavCalendar
|
||||
for (bunch in toDownload.chunked(MULTIGET_MAX_RESOURCES)) {
|
||||
if (bunch.size == 1)
|
||||
// only one contact, use GET
|
||||
useRemote(DavResource(httpClient.okHttpClient, bunch.first()), { remote ->
|
||||
remote.get(DavCalendar.MIME_ICALENDAR.toString()).use { dav ->
|
||||
// CalDAV servers MUST return ETag on GET [https://tools.ietf.org/html/rfc4791#section-5.3.4]
|
||||
val eTag = dav[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received CalDAV GET response without ETag for ${remote.location}")
|
||||
|
||||
dav.body?.charStream()?.use { reader ->
|
||||
processVTodo(remote.fileName(), eTag, reader)
|
||||
}
|
||||
}
|
||||
})
|
||||
else {
|
||||
// multiple contacts, use multi-get
|
||||
davCollection.multiget(bunch).use { dav ->
|
||||
// process multiget results
|
||||
for (remote in dav.members)
|
||||
useRemote(remote, {
|
||||
val eTag = remote[GetETag::class.java]?.eTag
|
||||
?: throw DavException("Received multi-get response without ETag")
|
||||
|
||||
val calendarData = remote[CalendarData::class.java]
|
||||
val iCalendar = calendarData?.iCalendar
|
||||
?: throw DavException("Received multi-get response without task data")
|
||||
|
||||
processVTodo(remote.fileName(), eTag, StringReader(iCalendar))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
abortIfCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
private fun processVTodo(fileName: String, eTag: String, reader: Reader) {
|
||||
val tasks: List<Task>
|
||||
@@ -172,22 +161,22 @@ class TasksSyncManager(
|
||||
val newData = tasks.first()
|
||||
|
||||
// update local task, if it exists
|
||||
val localTask = localResources[fileName] as LocalTask?
|
||||
currentLocalResource = localTask
|
||||
if (localTask != null) {
|
||||
Logger.log.info("Updating $fileName in local tasklist")
|
||||
localTask.eTag = eTag
|
||||
localTask.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local task list")
|
||||
val newTask = LocalTask(localTaskList, newData, fileName, eTag)
|
||||
currentLocalResource = newTask
|
||||
newTask.add()
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
useLocal(localCollection.findByName(fileName), { local ->
|
||||
if (local != null) {
|
||||
Logger.log.info("Updating $fileName in local task list")
|
||||
local.eTag = eTag
|
||||
local.update(newData)
|
||||
syncResult.stats.numUpdates++
|
||||
} else {
|
||||
Logger.log.info("Adding $fileName to local task list")
|
||||
useLocal(LocalTask(localCollection, newData, fileName, eTag, LocalResource.FLAG_REMOTELY_PRESENT), {
|
||||
it.add()
|
||||
})
|
||||
syncResult.stats.numInserts++
|
||||
}
|
||||
})
|
||||
} else
|
||||
Logger.log.severe("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
Logger.log.info("Received VCALENDAR with not exactly one VTODO; ignoring $fileName")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class AboutActivity: AppCompatActivity() {
|
||||
ComponentInfo(
|
||||
null, BuildConfig.VERSION_NAME, getString(R.string.homepage_url),
|
||||
"Ricki Hirner, Bernhard Stockmann (bitfire web engineering)",
|
||||
null, null
|
||||
R.string.about_license_info_no_warranty, "gpl-3.0-standalone.html"
|
||||
), ComponentInfo(
|
||||
"AmbilWarna", null, "https://github.com/yukuku/ambilwarna",
|
||||
"Yuku", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
@@ -70,14 +70,11 @@ class AboutActivity: AppCompatActivity() {
|
||||
"ez-vcard", Ezvcard.VERSION, "https://github.com/mangstadt/ez-vcard",
|
||||
"Michael Angstadt", R.string.about_license_info_no_warranty, "bsd.html"
|
||||
), ComponentInfo(
|
||||
"ical4j", "2.x", "https://ical4j.github.io/",
|
||||
"ical4j", at.bitfire.ical4android.Constants.ical4jVersion, "https://ical4j.github.io/",
|
||||
"Ben Fortuna", R.string.about_license_info_no_warranty, "bsd-3clause.html"
|
||||
), ComponentInfo(
|
||||
"OkHttp", null, "https://square.github.io/okhttp/",
|
||||
"OkHttp", at.bitfire.dav4android.Constants.okHttpVersion, "https://square.github.io/okhttp/",
|
||||
"Square, Inc.", R.string.about_license_info_no_warranty, "apache2.html"
|
||||
), ComponentInfo(
|
||||
"Project Lombok", null, "https://projectlombok.org/",
|
||||
"The Project Lombok Authors", R.string.about_license_info_no_warranty, "mit.html"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -107,8 +104,8 @@ class AboutActivity: AppCompatActivity() {
|
||||
class ComponentFragment: Fragment(), LoaderManager.LoaderCallbacks<Spanned> {
|
||||
|
||||
companion object {
|
||||
val KEY_POSITION = "position"
|
||||
val KEY_FILE_NAME = "fileName"
|
||||
const val KEY_POSITION = "position"
|
||||
const val KEY_FILE_NAME = "fileName"
|
||||
|
||||
fun instantiate(position: Int): ComponentFragment {
|
||||
val frag = ComponentFragment()
|
||||
@@ -168,8 +165,8 @@ class AboutActivity: AppCompatActivity() {
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle) =
|
||||
LicenseLoader(activity!!, args.getString(KEY_FILE_NAME))
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
LicenseLoader(activity!!, args!!.getString(KEY_FILE_NAME))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Spanned>, license: Spanned?) {
|
||||
view?.let { v ->
|
||||
@@ -186,13 +183,13 @@ class AboutActivity: AppCompatActivity() {
|
||||
val fileName: String
|
||||
): AsyncTaskLoader<Spanned>(context) {
|
||||
|
||||
var content: Spanned? = null
|
||||
private var content: Spanned? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
if (content == null)
|
||||
forceLoad()
|
||||
else
|
||||
if (content != null)
|
||||
deliverResult(content)
|
||||
else
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Spanned? {
|
||||
|
||||
@@ -42,7 +42,6 @@ import at.bitfire.davdroid.model.ServiceDB.Collections
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import kotlinx.android.synthetic.main.account_caldav_item.view.*
|
||||
import kotlinx.android.synthetic.main.activity_account.*
|
||||
import java.util.*
|
||||
@@ -51,7 +50,7 @@ import java.util.logging.Level
|
||||
class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, PopupMenu.OnMenuItemClickListener, LoaderManager.LoaderCallbacks<AccountActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
@JvmField val EXTRA_ACCOUNT = "account"
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
|
||||
private fun requestSync(context: Context, account: Account) {
|
||||
val authorities = arrayOf(
|
||||
@@ -77,7 +76,9 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = intent.getParcelableExtra(EXTRA_ACCOUNT)
|
||||
// account may be a DAVdroid address book account -> use main account in this case
|
||||
account = LocalAddressBook.mainAccount(this,
|
||||
requireNotNull(intent.getParcelableExtra(EXTRA_ACCOUNT)))
|
||||
title = account.name
|
||||
|
||||
setContentView(R.layout.activity_account)
|
||||
@@ -206,9 +207,6 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
|
||||
info.selected = nowChecked
|
||||
adapter.notifyDataSetChanged()
|
||||
|
||||
if (nowChecked && info.type == CollectionInfo.Type.ADDRESS_BOOK && info.readOnly)
|
||||
Snackbar.make(parent, R.string.account_read_only_address_book_selected, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private val onActionOverflowListener = { anchor: View, info: CollectionInfo ->
|
||||
@@ -240,7 +238,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
}
|
||||
}
|
||||
R.id.delete_collection ->
|
||||
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(getSupportFragmentManager(), null);
|
||||
DeleteCollectionFragment.ConfirmDeleteCollectionFragment.newInstance(account, info).show(supportFragmentManager, null)
|
||||
}
|
||||
true
|
||||
})
|
||||
@@ -363,6 +361,33 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
else
|
||||
View.GONE
|
||||
} ?: View.GONE
|
||||
|
||||
// ask for permissions
|
||||
val requiredPermissions = mutableSetOf<String>()
|
||||
info?.carddav?.let { carddav ->
|
||||
if (carddav.collections.any { it.type == CollectionInfo.Type.ADDRESS_BOOK }) {
|
||||
requiredPermissions += Manifest.permission.READ_CONTACTS
|
||||
requiredPermissions += Manifest.permission.WRITE_CONTACTS
|
||||
}
|
||||
}
|
||||
|
||||
info?.caldav?.let { caldav ->
|
||||
if (caldav.collections.any { it.type == CollectionInfo.Type.CALENDAR }) {
|
||||
requiredPermissions += Manifest.permission.READ_CALENDAR
|
||||
requiredPermissions += Manifest.permission.WRITE_CALENDAR
|
||||
|
||||
if (LocalTaskList.tasksProviderAvailable(this)) {
|
||||
requiredPermissions += TaskProvider.PERMISSION_READ_TASKS
|
||||
requiredPermissions += TaskProvider.PERMISSION_WRITE_TASKS
|
||||
}
|
||||
}
|
||||
if (caldav.collections.any { it.type == CollectionInfo.Type.WEBCAL })
|
||||
requiredPermissions += Manifest.permission.READ_CALENDAR
|
||||
}
|
||||
|
||||
val askPermissions = requiredPermissions.filter { ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }
|
||||
if (askPermissions.isNotEmpty())
|
||||
ActivityCompat.requestPermissions(this, askPermissions.toTypedArray(), 0)
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<AccountInfo>) {
|
||||
@@ -372,44 +397,58 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
|
||||
|
||||
class AccountLoader(
|
||||
val activity: AccountActivity,
|
||||
context: Context,
|
||||
val account: Account
|
||||
): AsyncTaskLoader<AccountInfo>(activity), DavService.RefreshingStatusListener, ServiceConnection, SyncStatusObserver {
|
||||
): AsyncTaskLoader<AccountInfo>(context), DavService.RefreshingStatusListener, SyncStatusObserver {
|
||||
|
||||
private var syncStatusListener: Any? = null
|
||||
|
||||
private var davServiceConn: ServiceConnection? = null
|
||||
private var davService: DavService.InfoBinder? = null
|
||||
private lateinit var syncStatusListener: Any
|
||||
|
||||
override fun onStartLoading() {
|
||||
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
|
||||
context.bindService(Intent(context, DavService::class.java), this, Context.BIND_AUTO_CREATE)
|
||||
// get notified when sync status changes
|
||||
if (syncStatusListener == null)
|
||||
syncStatusListener = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_ACTIVE, this)
|
||||
|
||||
// bind to DavService to get notified when it's running
|
||||
if (davServiceConn == null) {
|
||||
davServiceConn = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
// get notified when DavService is running
|
||||
davService = service as DavService.InfoBinder
|
||||
service.addRefreshingStatusListener(this@AccountLoader, false)
|
||||
|
||||
onContentChanged()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
davService = null
|
||||
}
|
||||
}
|
||||
context.bindService(Intent(context, DavService::class.java), davServiceConn, Context.BIND_AUTO_CREATE)
|
||||
} else
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onStopLoading() {
|
||||
davService?.removeRefreshingStatusListener(this)
|
||||
context.unbindService(this)
|
||||
override fun onReset() {
|
||||
ContentResolver.removeStatusChangeListener(syncStatusListener)
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
davService = service as DavService.InfoBinder
|
||||
service.addRefreshingStatusListener(this, false)
|
||||
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
davService = null
|
||||
davService?.removeRefreshingStatusListener(this)
|
||||
davServiceConn?.let {
|
||||
context.unbindService(it)
|
||||
davServiceConn = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDavRefreshStatusChanged(id: Long, refreshing: Boolean) =
|
||||
forceLoad()
|
||||
onContentChanged()
|
||||
|
||||
override fun onStatusChanged(which: Int) =
|
||||
forceLoad()
|
||||
onContentChanged()
|
||||
|
||||
override fun loadInBackground(): AccountInfo {
|
||||
val info = AccountInfo()
|
||||
val requiredPermissions = mutableSetOf<String>()
|
||||
|
||||
OpenHelper(context).use { dbHelper ->
|
||||
val db = dbHelper.readableDatabase
|
||||
@@ -433,14 +472,14 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
for (addrBookAccount in accountManager.getAccountsByType(context.getString(R.string.account_type_address_book))) {
|
||||
val addressBook = LocalAddressBook(context, addrBookAccount, null)
|
||||
try {
|
||||
if (account == addressBook.getMainAccount())
|
||||
if (account == addressBook.mainAccount)
|
||||
carddav.refreshing = carddav.refreshing || ContentResolver.isSyncActive(addrBookAccount, ContactsContract.AUTHORITY)
|
||||
} catch(e: ContactsStorageException) {
|
||||
} catch(e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
carddav.hasHomeSets = hasHomeSets(db, id)
|
||||
carddav.collections = readCollections(db, id, requiredPermissions)
|
||||
carddav.collections = readCollections(db, id)
|
||||
}
|
||||
Services.SERVICE_CALDAV -> {
|
||||
val caldav = AccountInfo.ServiceInfo()
|
||||
@@ -451,17 +490,13 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
ContentResolver.isSyncActive(account, CalendarContract.AUTHORITY) ||
|
||||
ContentResolver.isSyncActive(account, TaskProvider.ProviderName.OpenTasks.authority)
|
||||
caldav.hasHomeSets = hasHomeSets(db, id)
|
||||
caldav.collections = readCollections(db, id, requiredPermissions)
|
||||
caldav.collections = readCollections(db, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val askPermissions = requiredPermissions.filter { ActivityCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED }
|
||||
if (askPermissions.isNotEmpty())
|
||||
ActivityCompat.requestPermissions(activity, askPermissions.toTypedArray(), 0)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
@@ -473,7 +508,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
return false
|
||||
}
|
||||
|
||||
private fun readCollections(db: SQLiteDatabase, service: Long, requiredPermissions: MutableSet<String>): List<CollectionInfo> {
|
||||
private fun readCollections(db: SQLiteDatabase, service: Long): List<CollectionInfo> {
|
||||
val collections = LinkedList<CollectionInfo>()
|
||||
db.query(Collections._TABLE, null, Collections.SERVICE_ID + "=?", arrayOf(service.toString()),
|
||||
null, null, "${Collections.SUPPORTS_VEVENT} DESC,${Collections.DISPLAY_NAME}").use { cursor ->
|
||||
@@ -502,19 +537,6 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
}
|
||||
}
|
||||
|
||||
if (collections.any { it.type == CollectionInfo.Type.ADDRESS_BOOK } && ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
|
||||
requiredPermissions += Manifest.permission.READ_CONTACTS
|
||||
requiredPermissions += Manifest.permission.WRITE_CONTACTS
|
||||
}
|
||||
if (collections.any { it.type == CollectionInfo.Type.CALENDAR }) {
|
||||
requiredPermissions += Manifest.permission.READ_CALENDAR
|
||||
requiredPermissions += Manifest.permission.WRITE_CALENDAR
|
||||
requiredPermissions += TaskProvider.PERMISSION_READ_TASKS
|
||||
requiredPermissions += TaskProvider.PERMISSION_WRITE_TASKS
|
||||
}
|
||||
if (collections.any { it.type == CollectionInfo.Type.WEBCAL })
|
||||
requiredPermissions += Manifest.permission.READ_CALENDAR
|
||||
|
||||
return collections
|
||||
}
|
||||
|
||||
@@ -534,7 +556,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
checked.isChecked = info.selected
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url.toString()
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
@@ -578,7 +600,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
} ?: View.INVISIBLE
|
||||
|
||||
var tv: TextView = v.findViewById(R.id.title)
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url
|
||||
tv.text = if (!info.displayName.isNullOrBlank()) info.displayName else info.url.toString()
|
||||
|
||||
tv = v.findViewById(R.id.description)
|
||||
if (info.description.isNullOrBlank())
|
||||
@@ -618,7 +640,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
|
||||
companion object {
|
||||
|
||||
val ARG_ACCOUNT = "account"
|
||||
const val ARG_ACCOUNT = "account"
|
||||
|
||||
fun newInstance(account: Account): RenameAccountFragment {
|
||||
val fragment = RenameAccountFragment()
|
||||
@@ -640,7 +662,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
.setTitle(R.string.account_rename)
|
||||
.setMessage(R.string.account_rename_new_name)
|
||||
.setView(editText)
|
||||
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { dialog, which ->
|
||||
.setPositiveButton(R.string.account_rename_rename, DialogInterface.OnClickListener { _, _ ->
|
||||
val newName = editText.text.toString()
|
||||
|
||||
if (newName == oldAccount.name)
|
||||
@@ -668,8 +690,8 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
try {
|
||||
if (provider != null) {
|
||||
val addressBook = LocalAddressBook(activity!!, addrBookAccount, provider)
|
||||
if (oldAccount == addressBook.getMainAccount())
|
||||
addressBook.setMainAccount(Account(newName, oldAccount.type))
|
||||
if (oldAccount == addressBook.mainAccount)
|
||||
addressBook.mainAccount = Account(newName, oldAccount.type)
|
||||
}
|
||||
} finally {
|
||||
if (Build.VERSION.SDK_INT >= 24)
|
||||
@@ -679,7 +701,7 @@ class AccountActivity: AppCompatActivity(), Toolbar.OnMenuItemClickListener, Pop
|
||||
}
|
||||
|
||||
}
|
||||
} catch(e: ContactsStorageException) {
|
||||
} catch(e: Exception) {
|
||||
Logger.log.log(Level.SEVERE, "Couldn't update address book accounts", e)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import android.view.ViewGroup
|
||||
import android.widget.AbsListView
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import at.bitfire.davdroid.AccountsChangedReceiver
|
||||
import at.bitfire.davdroid.R
|
||||
import kotlinx.android.synthetic.main.account_list_item.view.*
|
||||
|
||||
@@ -67,16 +66,28 @@ class AccountListFragment: ListFragment(), LoaderManager.LoaderCallbacks<Array<A
|
||||
|
||||
class AccountLoader(
|
||||
context: Context
|
||||
): AsyncTaskLoader<Array<Account>>(context), OnAccountsUpdateListener {
|
||||
): AsyncTaskLoader<Array<Account>>(context) {
|
||||
|
||||
override fun onStartLoading() =
|
||||
AccountsChangedReceiver.registerListener(this, true)
|
||||
private val accountManager = AccountManager.get(context)!!
|
||||
private var listener: OnAccountsUpdateListener? = null
|
||||
|
||||
override fun onStopLoading() =
|
||||
AccountsChangedReceiver.unregisterListener(this)
|
||||
override fun onStartLoading() {
|
||||
if (listener == null) {
|
||||
listener = OnAccountsUpdateListener { onContentChanged() }
|
||||
accountManager.addOnAccountsUpdatedListener(listener, null, false)
|
||||
}
|
||||
|
||||
override fun onAccountsUpdated(accounts: Array<Account>?) =
|
||||
forceLoad()
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onReset() {
|
||||
listener?.let {
|
||||
try {
|
||||
accountManager.removeOnAccountsUpdatedListener(it)
|
||||
} catch(ignored: IllegalArgumentException) {}
|
||||
listener = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Array<Account> =
|
||||
AccountManager.get(context).getAccountsByType(context.getString(R.string.account_type))
|
||||
|
||||
@@ -8,24 +8,33 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.app.DialogFragment
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.CalendarContract
|
||||
import android.support.v14.preference.PreferenceFragment
|
||||
import android.security.KeyChain
|
||||
import android.support.v4.app.ActivityCompat
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.app.NavUtils
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AlertDialog
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.support.v7.preference.EditTextPreference
|
||||
import android.support.v7.preference.ListPreference
|
||||
import android.support.v7.preference.Preference
|
||||
import android.support.v7.preference.SwitchPreferenceCompat
|
||||
import android.support.v7.preference.*
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.AccountSettings
|
||||
import at.bitfire.davdroid.InvalidAccountException
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.GroupMethod
|
||||
@@ -34,7 +43,7 @@ import org.apache.commons.lang3.StringUtils
|
||||
class AccountSettingsActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
@@ -49,7 +58,7 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
if (savedInstanceState == null)
|
||||
fragmentManager.beginTransaction()
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(android.R.id.content, DialogFragment.instantiate(this, AccountSettingsFragment::class.java.name, intent.extras))
|
||||
.commit()
|
||||
}
|
||||
@@ -64,13 +73,13 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
false
|
||||
|
||||
|
||||
class AccountSettingsFragment: PreferenceFragment(), LoaderManager.LoaderCallbacks<Pair<ISettings, AccountSettings>?> {
|
||||
class AccountSettingsFragment: PreferenceFragmentCompat(), LoaderManager.LoaderCallbacks<Pair<ISettings, AccountSettings>> {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = arguments.getParcelable(EXTRA_ACCOUNT)
|
||||
account = arguments!!.getParcelable(EXTRA_ACCOUNT)
|
||||
loaderManager.initLoader(0, arguments, this)
|
||||
}
|
||||
|
||||
@@ -78,27 +87,54 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
addPreferencesFromResource(R.xml.settings_account)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle) =
|
||||
AccountSettingsLoader(activity, args.getParcelable(EXTRA_ACCOUNT))
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
AccountSettingsLoader(requireActivity(), args!!.getParcelable(EXTRA_ACCOUNT))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Pair<ISettings, AccountSettings>?>, result: Pair<ISettings, AccountSettings>?) {
|
||||
override fun onLoadFinished(loader: Loader<Pair<ISettings, AccountSettings>>, result: Pair<ISettings, AccountSettings>?) {
|
||||
val (settings, accountSettings) = result ?: return
|
||||
|
||||
// preference group: authentication
|
||||
val prefUserName = findPreference("username") as EditTextPreference
|
||||
prefUserName.summary = accountSettings.username()
|
||||
prefUserName.text = accountSettings.username()
|
||||
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.username(newValue as String)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
val prefPassword = findPreference("password") as EditTextPreference
|
||||
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.password(newValue as String)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
val prefCertAlias = findPreference("certificate_alias") as Preference
|
||||
|
||||
val credentials = accountSettings.credentials()
|
||||
when (credentials.type) {
|
||||
Credentials.Type.UsernamePassword -> {
|
||||
prefUserName.isVisible = true
|
||||
prefUserName.summary = credentials.userName
|
||||
prefUserName.text = credentials.userName
|
||||
prefUserName.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.credentials(Credentials(newValue as String, credentials.password))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
prefPassword.isVisible = true
|
||||
prefPassword.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.credentials(Credentials(credentials.userName, newValue as String))
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
prefCertAlias.isVisible = false
|
||||
}
|
||||
Credentials.Type.ClientCertificate -> {
|
||||
prefUserName.isVisible = false
|
||||
prefPassword.isVisible = false
|
||||
|
||||
prefCertAlias.isVisible = true
|
||||
prefCertAlias.summary = credentials.certificateAlias
|
||||
prefCertAlias.setOnPreferenceClickListener {
|
||||
KeyChain.choosePrivateKeyAlias(activity, { alias ->
|
||||
accountSettings.credentials(Credentials(certificateAlias = alias))
|
||||
Handler(Looper.getMainLooper()).post({
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
})
|
||||
}, null, null, null, -1, credentials.certificateAlias)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// preference group: sync
|
||||
@@ -175,11 +211,18 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
else
|
||||
prefWifiOnlySSIDs.setSummary(R.string.settings_sync_wifi_only_ssids_off)
|
||||
prefWifiOnlySSIDs.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').map { StringUtils.trimToNull(it) }.filterNotNull().distinct())
|
||||
accountSettings.setSyncWifiOnlySSIDs((newValue as String).split(',').mapNotNull { StringUtils.trimToNull(it) }.distinct())
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
false
|
||||
}
|
||||
|
||||
// getting the WiFi name requires location permission (and active location services) since Android 8.1
|
||||
// see https://issuetracker.google.com/issues/70633700
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 &&
|
||||
accountSettings.getSyncWifiOnly() && onlySSIDs != null &&
|
||||
ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED)
|
||||
ActivityCompat.requestPermissions(requireActivity(), arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), 0)
|
||||
|
||||
// preference group: CardDAV
|
||||
(findPreference("contact_group_method") as ListPreference).let {
|
||||
if (syncIntervalContacts != null) {
|
||||
@@ -191,7 +234,7 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
else {
|
||||
it.isEnabled = true
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, groupMethod ->
|
||||
AlertDialog.Builder(activity)
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.settings_contact_group_method_change)
|
||||
.setMessage(R.string.settings_contact_group_method_change_reload_contacts)
|
||||
@@ -227,11 +270,10 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
it.setSummary(R.string.settings_sync_time_range_past_none)
|
||||
}
|
||||
it.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
var days: Int
|
||||
try {
|
||||
days = (newValue as String).toInt()
|
||||
} catch(ignored: NumberFormatException) {
|
||||
days = -1
|
||||
val days = try {
|
||||
(newValue as String).toInt()
|
||||
} catch(e: NumberFormatException) {
|
||||
-1
|
||||
}
|
||||
accountSettings.setTimeRangePastDays(if (days < 0) null else days)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
@@ -265,7 +307,7 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
accountSettings.setEventColors(true)
|
||||
loaderManager.restartLoader(0, arguments, this)
|
||||
} else
|
||||
AlertDialog.Builder(activity)
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(R.string.settings_event_colors)
|
||||
.setMessage(R.string.settings_event_colors_off_confirm)
|
||||
@@ -282,7 +324,7 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Pair<ISettings, AccountSettings>?>) {
|
||||
override fun onLoaderReset(loader: Loader<Pair<ISettings, AccountSettings>>) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -293,17 +335,22 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
val account: Account
|
||||
): SettingsLoader<Pair<ISettings, AccountSettings>?>(context), SyncStatusObserver {
|
||||
|
||||
var listenerHandle: Any? = null
|
||||
private var listenerHandle: Any? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
super.onStartLoading()
|
||||
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this@AccountSettingsLoader)
|
||||
|
||||
if (listenerHandle == null)
|
||||
listenerHandle = ContentResolver.addStatusChangeListener(ContentResolver.SYNC_OBSERVER_TYPE_SETTINGS, this)
|
||||
}
|
||||
|
||||
override fun onStopLoading() {
|
||||
super.onStopLoading()
|
||||
listenerHandle?.let { ContentResolver.removeStatusChangeListener(it) }
|
||||
listenerHandle = null
|
||||
override fun onReset() {
|
||||
super.onReset()
|
||||
|
||||
listenerHandle?.let {
|
||||
ContentResolver.removeStatusChangeListener(it)
|
||||
listenerHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Pair<ISettings, AccountSettings>? {
|
||||
@@ -320,7 +367,7 @@ class AccountSettingsActivity: AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onStatusChanged(which: Int) {
|
||||
forceLoad()
|
||||
onContentChanged()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,33 +10,32 @@ package at.bitfire.davdroid.ui
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SyncStatusObserver
|
||||
import android.net.Uri
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.os.Bundle
|
||||
import android.support.design.widget.NavigationView
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v4.view.GravityCompat
|
||||
import android.support.v7.app.ActionBarDrawerToggle
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.ui.setup.LoginActivity
|
||||
import kotlinx.android.synthetic.main.accounts_content.*
|
||||
import kotlinx.android.synthetic.main.activity_accounts.*
|
||||
import java.util.*
|
||||
|
||||
class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener, LoaderManager.LoaderCallbacks<AccountsActivity.Settings>, SyncStatusObserver {
|
||||
|
||||
companion object {
|
||||
private val EXTRA_CREATE_STARTUP_FRAGMENTS = "createStartupFragments"
|
||||
val accountsDrawerHandler = DefaultAccountsDrawerHandler()
|
||||
|
||||
private const val fragTagStartup = "startup"
|
||||
}
|
||||
|
||||
private var syncStatusSnackbar: Snackbar? = null
|
||||
@@ -50,7 +49,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
fab.setOnClickListener({
|
||||
startActivity(Intent(this@AccountsActivity, LoginActivity::class.java))
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
})
|
||||
|
||||
val toggle = ActionBarDrawerToggle(
|
||||
@@ -60,8 +59,6 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
|
||||
nav_view.setNavigationItemSelectedListener(this)
|
||||
nav_view.itemIconTintList = null
|
||||
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
|
||||
nav_view.menu.findItem(R.id.nav_beta_feedback).isVisible = true
|
||||
|
||||
/* When the DAVdroid main activity is started, start a Settings service that stays in memory
|
||||
for better performance. The service stops itself when memory is trimmed. */
|
||||
@@ -69,24 +66,30 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
startService(settingsIntent)
|
||||
|
||||
val args = Bundle(1)
|
||||
args.putBoolean(EXTRA_CREATE_STARTUP_FRAGMENTS, savedInstanceState == null && packageName != callingPackage)
|
||||
loaderManager.initLoader(0, args, this)
|
||||
supportLoaderManager.initLoader(0, args, this)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(code: Int, args: Bundle) =
|
||||
SettingsLoader(this, args.getBoolean(EXTRA_CREATE_STARTUP_FRAGMENTS))
|
||||
override fun onCreateLoader(code: Int, args: Bundle?) =
|
||||
SettingsLoader(this)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Settings>?, result: Settings?) {
|
||||
override fun onLoadFinished(loader: Loader<Settings>, result: Settings?) {
|
||||
val result = result ?: return
|
||||
|
||||
if (result.createStartupFragments) {
|
||||
val ft = fragmentManager.beginTransaction()
|
||||
StartupDialogFragment.getStartupDialogs(this, result.settings).forEach { ft.add(it, null) }
|
||||
ft.commitAllowingStateLoss()
|
||||
if (supportFragmentManager.findFragmentByTag(fragTagStartup) == null) {
|
||||
val ft = supportFragmentManager.beginTransaction()
|
||||
StartupDialogFragment.getStartupDialogs(this, result.settings).forEach { ft.add(it, fragTagStartup) }
|
||||
ft.commit()
|
||||
}
|
||||
|
||||
nav_view?.menu?.let {
|
||||
accountsDrawerHandler.onSettingsChanged(result.settings, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Settings>?) {
|
||||
override fun onLoaderReset(loader: Loader<Settings>) {
|
||||
nav_view?.menu?.let {
|
||||
accountsDrawerHandler.onSettingsChanged(null, it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -131,43 +134,18 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(item: MenuItem): Boolean {
|
||||
var processed = true
|
||||
when (item.itemId) {
|
||||
R.id.nav_about ->
|
||||
startActivity(Intent(this, AboutActivity::class.java))
|
||||
R.id.nav_app_settings ->
|
||||
startActivity(Intent(this, AppSettingsActivity::class.java))
|
||||
R.id.nav_beta_feedback ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.beta_feedback_url))))
|
||||
R.id.nav_twitter ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
|
||||
R.id.nav_website ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))))
|
||||
R.id.nav_faq ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.navigation_drawer_faq_url))))
|
||||
R.id.nav_forums ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("forums/").build()))
|
||||
R.id.nav_donate ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("donate/").build()))
|
||||
else ->
|
||||
processed = false
|
||||
}
|
||||
|
||||
val processed = accountsDrawerHandler.onNavigationItemSelected(this, item)
|
||||
drawer_layout.closeDrawer(GravityCompat.START)
|
||||
return processed
|
||||
}
|
||||
|
||||
|
||||
class Settings(
|
||||
val settings: ISettings,
|
||||
val createStartupFragments: Boolean
|
||||
val settings: ISettings
|
||||
)
|
||||
|
||||
class SettingsLoader(
|
||||
context: Context,
|
||||
private val createStartupFragments: Boolean
|
||||
context: Context
|
||||
): at.bitfire.davdroid.ui.SettingsLoader<Settings>(context) {
|
||||
|
||||
override fun loadInBackground(): Settings? {
|
||||
@@ -176,8 +154,7 @@ class AccountsActivity: AppCompatActivity(), NavigationView.OnNavigationItemSele
|
||||
val accounts = accountManager.getAccountsByType(context.getString(R.string.account_type))
|
||||
|
||||
return Settings(
|
||||
it,
|
||||
createStartupFragments
|
||||
it
|
||||
)
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -36,7 +36,7 @@ import java.net.URISyntaxException
|
||||
class AppSettingsActivity: AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
val EXTRA_SCROLL_TO = "scrollTo"
|
||||
const val EXTRA_SCROLL_TO = "scrollTo"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -98,11 +98,11 @@ class AppSettingsActivity: AppCompatActivity() {
|
||||
// security settings
|
||||
val prefDistrustSystemCerts = findPreference(App.DISTRUST_SYSTEM_CERTIFICATES)
|
||||
prefDistrustSystemCerts.isVisible = BuildConfig.customCerts
|
||||
prefDistrustSystemCerts.isEnabled = BuildConfig.customCertsUI
|
||||
prefDistrustSystemCerts.isEnabled = true
|
||||
|
||||
val prefResetCertificates = findPreference("reset_certificates")
|
||||
prefResetCertificates.isVisible = BuildConfig.customCerts
|
||||
prefResetCertificates.isEnabled = BuildConfig.customCertsUI
|
||||
prefResetCertificates.isEnabled = true
|
||||
prefResetCertificates.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
resetCertificates()
|
||||
false
|
||||
@@ -164,8 +164,11 @@ class AppSettingsActivity: AppCompatActivity() {
|
||||
// debugging settings
|
||||
val prefLogToExternalStorage = findPreference(Logger.LOG_TO_EXTERNAL_STORAGE) as SwitchPreferenceCompat
|
||||
prefLogToExternalStorage.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
|
||||
val context = activity!!
|
||||
Logger.initialize(context)
|
||||
|
||||
// kill a potential :sync process, so that the new logger settings will be used
|
||||
val am = activity!!.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
am.runningAppProcesses.forEach {
|
||||
if (it.pid != Process.myPid()) {
|
||||
Logger.log.info("Killing ${it.processName} process, pid = ${it.pid}")
|
||||
@@ -177,6 +180,7 @@ class AppSettingsActivity: AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun resetHints() {
|
||||
settings?.remove(StartupDialogFragment.HINT_AUTOSTART_PERMISSIONS)
|
||||
settings?.remove(StartupDialogFragment.HINT_BATTERY_OPTIMIZATIONS)
|
||||
settings?.remove(StartupDialogFragment.HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED)
|
||||
settings?.remove(StartupDialogFragment.HINT_OPENTASKS_NOT_INSTALLED)
|
||||
@@ -184,7 +188,7 @@ class AppSettingsActivity: AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun resetCertificates() {
|
||||
if (BuildConfig.customCertsUI && CustomCertManager.resetCertificates(activity!!))
|
||||
if (CustomCertManager.resetCertificates(activity!!))
|
||||
Snackbar.make(view!!, getString(R.string.app_settings_reset_certificates_success), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ import java.util.*
|
||||
class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateAddressBookActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
@@ -67,7 +67,7 @@ class CreateAddressBookActivity: AppCompatActivity(), LoaderManager.LoaderCallba
|
||||
|
||||
var ok = true
|
||||
HttpUrl.parse(homeSet)?.let {
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/")!!)
|
||||
info.displayName = display_name.text.toString()
|
||||
if (info.displayName.isNullOrBlank()) {
|
||||
display_name.error = getString(R.string.create_collection_display_name_required)
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.util.*
|
||||
class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<CreateCalendarActivity.AccountInfo> {
|
||||
|
||||
companion object {
|
||||
val EXTRA_ACCOUNT = "account"
|
||||
const val EXTRA_ACCOUNT = "account"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
@@ -78,7 +78,7 @@ class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks
|
||||
|
||||
var ok = true
|
||||
HttpUrl.parse(homeSet)?.let {
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/").toString())
|
||||
val info = CollectionInfo(it.resolve(UUID.randomUUID().toString() + "/")!!)
|
||||
info.displayName = display_name.text.toString()
|
||||
if (info.displayName.isNullOrBlank()) {
|
||||
display_name.error = getString(R.string.create_collection_display_name_required)
|
||||
@@ -121,7 +121,7 @@ class CreateCalendarActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks
|
||||
|
||||
// select system time zone
|
||||
val defaultTimeZone = TimeZone.getDefault().id
|
||||
for (i in 0 .. timeZones.size - 1)
|
||||
for (i in 0 until timeZones.size)
|
||||
if (timeZones[i] == defaultTimeZone) {
|
||||
time_zone.setSelection(i)
|
||||
break
|
||||
|
||||
@@ -27,7 +27,6 @@ import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import okhttp3.HttpUrl
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
import java.util.logging.Level
|
||||
@@ -37,14 +36,14 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
|
||||
companion object {
|
||||
|
||||
val ARG_ACCOUNT = "account"
|
||||
val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
|
||||
fun newInstance(account: Account, info: CollectionInfo): CreateCollectionFragment {
|
||||
val frag = CreateCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putSerializable(ARG_COLLECTION_INFO, info)
|
||||
args.putParcelable(ARG_COLLECTION_INFO, info)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
@@ -57,8 +56,9 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
account = arguments!!.getParcelable(ARG_ACCOUNT)
|
||||
info = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
val args = requireNotNull(arguments)
|
||||
account = args.getParcelable(ARG_ACCOUNT)
|
||||
info = args.getParcelable(ARG_COLLECTION_INFO)
|
||||
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
@@ -74,16 +74,16 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(activity!!, account, info)
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) = CreateCollectionLoader(requireActivity(), account, info)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
|
||||
dismissAllowingStateLoss()
|
||||
dismiss()
|
||||
|
||||
activity?.let { parent ->
|
||||
if (exception != null)
|
||||
fragmentManager!!.beginTransaction()
|
||||
requireFragmentManager().beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, account), null)
|
||||
.commitAllowingStateLoss()
|
||||
.commit()
|
||||
else
|
||||
parent.finish()
|
||||
}
|
||||
@@ -189,7 +189,7 @@ class CreateCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
try {
|
||||
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(info.url)!!)
|
||||
val collection = DavResource(httpClient.okHttpClient, info.url)
|
||||
|
||||
// create collection on remote server
|
||||
collection.mkCol(writer.toString())
|
||||
|
||||
@@ -11,8 +11,9 @@ package at.bitfire.davdroid.ui
|
||||
import android.Manifest
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
@@ -20,9 +21,13 @@ import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.CalendarContract
|
||||
import android.provider.ContactsContract
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.ContextCompat
|
||||
import android.support.v4.content.FileProvider
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
@@ -35,9 +40,7 @@ import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.resource.LocalAddressBook
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import at.bitfire.vcard4android.ContactsStorageException
|
||||
import kotlinx.android.synthetic.main.activity_debug_info.*
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.IOException
|
||||
@@ -48,13 +51,13 @@ import java.util.logging.Level
|
||||
class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<String> {
|
||||
|
||||
companion object {
|
||||
@JvmField val KEY_THROWABLE = "throwable"
|
||||
@JvmField val KEY_LOGS = "logs"
|
||||
@JvmField val KEY_ACCOUNT = "account"
|
||||
@JvmField val KEY_AUTHORITY = "authority"
|
||||
@JvmField val KEY_PHASE = "phase"
|
||||
@JvmField val KEY_LOCAL_RESOURCE = "localResource"
|
||||
@JvmField val KEY_REMOTE_RESOURCE = "remoteResource"
|
||||
const val KEY_THROWABLE = "throwable"
|
||||
const val KEY_LOGS = "logs"
|
||||
const val KEY_ACCOUNT = "account"
|
||||
const val KEY_AUTHORITY = "authority"
|
||||
const val KEY_PHASE = "phase"
|
||||
const val KEY_LOCAL_RESOURCE = "localResource"
|
||||
const val KEY_REMOTE_RESOURCE = "remoteResource"
|
||||
}
|
||||
|
||||
private var report: String? = null
|
||||
@@ -63,7 +66,7 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_debug_info)
|
||||
|
||||
loaderManager.initLoader(0, intent.extras, this)
|
||||
supportLoaderManager.initLoader(0, intent.extras, this)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
@@ -120,9 +123,17 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
val extras: Bundle?
|
||||
): AsyncTaskLoader<String>(context) {
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
var result: String? = null
|
||||
|
||||
override fun onStartLoading() {
|
||||
if (result != null)
|
||||
deliverResult(result)
|
||||
else
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun loadInBackground(): String {
|
||||
Logger.log.info("Building debug info")
|
||||
val report = StringBuilder("--- BEGIN DEBUG INFO ---\n")
|
||||
|
||||
// begin with most specific information
|
||||
@@ -140,10 +151,14 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
val throwable = extras?.getSerializable(KEY_THROWABLE) as Throwable?
|
||||
if (throwable is HttpException) {
|
||||
throwable.request?.let {
|
||||
report.append("\nHTTP REQUEST:\n$it\n\n")
|
||||
report.append("\nHTTP REQUEST:\n$it\n")
|
||||
throwable.requestBody?.let { report.append(it) }
|
||||
report.append("\n\n")
|
||||
}
|
||||
throwable.response?.let {
|
||||
report.append("HTTP RESPONSE:\n$it\n")
|
||||
throwable.responseBody?.let { report.append(it) }
|
||||
report.append("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +170,7 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
}
|
||||
|
||||
throwable?.let {
|
||||
report.append("\nEXCEPTION:\n${ExceptionUtils.getStackTrace(throwable)}")
|
||||
report.append("\nEXCEPTION:\n${Log.getStackTraceString(throwable)}")
|
||||
}
|
||||
|
||||
// logs (for instance, from failed resource detection)
|
||||
@@ -245,10 +260,10 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
try {
|
||||
val addressBook = LocalAddressBook(context, acct, null)
|
||||
report.append("Address book account: ${acct.name}\n" +
|
||||
" Main account: ${addressBook.getMainAccount()}\n" +
|
||||
" URL: ${addressBook.getURL()}\n" +
|
||||
" Main account: ${addressBook.mainAccount}\n" +
|
||||
" URL: ${addressBook.url}\n" +
|
||||
" Sync automatically: ").append(ContentResolver.getSyncAutomatically(acct, ContactsContract.AUTHORITY)).append("\n")
|
||||
} catch(e: ContactsStorageException) {
|
||||
} catch(e: Exception) {
|
||||
report.append("$acct is invalid: ${e.message}\n")
|
||||
}
|
||||
report.append("\n")
|
||||
@@ -270,7 +285,11 @@ class DebugInfoActivity: AppCompatActivity(), LoaderManager.LoaderCallbacks<Stri
|
||||
}
|
||||
|
||||
report.append("--- END DEBUG INFO ---\n")
|
||||
return report.toString()
|
||||
|
||||
report.toString().let {
|
||||
result = it
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncStatus(settings: AccountSettings, authority: String): String {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
|
||||
class DefaultAccountsDrawerHandler: IAccountsDrawerHandler {
|
||||
|
||||
companion object {
|
||||
private const val BETA_FEEDBACK_URI = "mailto:support@davdroid.com?subject=${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME} feedback (${BuildConfig.VERSION_CODE})"
|
||||
}
|
||||
|
||||
|
||||
override fun onSettingsChanged(settings: ISettings?, menu: Menu) {
|
||||
if (BuildConfig.VERSION_NAME.contains("-beta") || BuildConfig.VERSION_NAME.contains("-rc"))
|
||||
menu.findItem(R.id.nav_beta_feedback).isVisible = true
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
R.id.nav_about ->
|
||||
activity.startActivity(Intent(activity, AboutActivity::class.java))
|
||||
R.id.nav_app_settings ->
|
||||
activity.startActivity(Intent(activity, AppSettingsActivity::class.java))
|
||||
R.id.nav_beta_feedback -> {
|
||||
val intent = Intent(Intent.ACTION_SENDTO, Uri.parse(BETA_FEEDBACK_URI))
|
||||
if (activity.packageManager.resolveActivity(intent, 0) != null)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
R.id.nav_twitter ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://twitter.com/davdroidapp")))
|
||||
R.id.nav_website ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))))
|
||||
R.id.nav_manual ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("manual/").build()))
|
||||
R.id.nav_faq ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("faq/").build()))
|
||||
R.id.nav_forums ->
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("forums/").build()))
|
||||
R.id.nav_donate ->
|
||||
//if (BuildConfig.FLAVOR != App.FLAVOR_GOOGLE_PLAY)
|
||||
activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(activity.getString(R.string.homepage_url))
|
||||
.buildUpon().appendEncodedPath("donate/").build()))
|
||||
else ->
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,14 +25,13 @@ import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.ServiceDB
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Exception> {
|
||||
|
||||
companion object {
|
||||
val ARG_ACCOUNT = "account"
|
||||
val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_COLLECTION_INFO = "collectionInfo"
|
||||
}
|
||||
|
||||
private lateinit var account: Account
|
||||
@@ -62,12 +61,12 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
DeleteCollectionLoader(activity!!, account, collectionInfo)
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Exception>, exception: Exception?) {
|
||||
dismissAllowingStateLoss()
|
||||
dismiss()
|
||||
|
||||
if (exception != null)
|
||||
fragmentManager!!.beginTransaction()
|
||||
requireFragmentManager().beginTransaction()
|
||||
.add(ExceptionInfoFragment.newInstance(exception, account), null)
|
||||
.commitAllowingStateLoss()
|
||||
.commit()
|
||||
else
|
||||
(activity as? AccountActivity)?.reload()
|
||||
}
|
||||
@@ -89,7 +88,7 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
.setForeground(true)
|
||||
.build().use { httpClient ->
|
||||
try {
|
||||
val collection = DavResource(httpClient.okHttpClient, HttpUrl.parse(collectionInfo.url)!!)
|
||||
val collection = DavResource(httpClient.okHttpClient, collectionInfo.url)
|
||||
|
||||
// delete collection from server
|
||||
collection.delete(null)
|
||||
@@ -117,7 +116,7 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
val frag = ConfirmDeleteCollectionFragment()
|
||||
val args = Bundle(2)
|
||||
args.putParcelable(ARG_ACCOUNT, account)
|
||||
args.putSerializable(ARG_COLLECTION_INFO, collectionInfo)
|
||||
args.putParcelable(ARG_COLLECTION_INFO, collectionInfo)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
@@ -125,9 +124,9 @@ class DeleteCollectionFragment: DialogFragment(), LoaderManager.LoaderCallbacks<
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val collectionInfo = arguments!!.getSerializable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
val collectionInfo = arguments!!.getParcelable(ARG_COLLECTION_INFO) as CollectionInfo
|
||||
val name = if (collectionInfo.displayName.isNullOrBlank())
|
||||
collectionInfo.url
|
||||
collectionInfo.url.toString()
|
||||
else
|
||||
collectionInfo.displayName
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import java.io.IOException
|
||||
class ExceptionInfoFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
val ARG_ACCOUNT = "account"
|
||||
val ARG_EXCEPTION = "exception"
|
||||
const val ARG_ACCOUNT = "account"
|
||||
const val ARG_EXCEPTION = "exception"
|
||||
|
||||
fun newInstance(exception: Exception, account: Account?): ExceptionInfoFragment {
|
||||
val frag = ExceptionInfoFragment()
|
||||
@@ -35,8 +35,9 @@ class ExceptionInfoFragment: DialogFragment() {
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val exception = arguments!!.getSerializable(ARG_EXCEPTION) as Exception
|
||||
val account: Account? = arguments!!.getParcelable(ARG_ACCOUNT)
|
||||
val args = requireNotNull(arguments)
|
||||
val exception = args.getSerializable(ARG_EXCEPTION) as Exception
|
||||
val account: Account? = args.getParcelable(ARG_ACCOUNT)
|
||||
|
||||
val title = when (exception) {
|
||||
is HttpException -> R.string.exception_httpexception
|
||||
@@ -44,7 +45,7 @@ class ExceptionInfoFragment: DialogFragment() {
|
||||
else -> R.string.exception
|
||||
}
|
||||
|
||||
val dialog = AlertDialog.Builder(activity!!)
|
||||
val dialog = AlertDialog.Builder(requireActivity())
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(title)
|
||||
.setMessage(exception::class.java.name + "\n" + exception.localizedMessage)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
|
||||
interface IAccountsDrawerHandler {
|
||||
|
||||
fun onSettingsChanged(settings: ISettings?, menu: Menu)
|
||||
|
||||
fun onNavigationItemSelected(activity: Activity, item: MenuItem): Boolean
|
||||
|
||||
}
|
||||
@@ -8,29 +8,68 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.support.v4.app.NotificationCompat
|
||||
import at.bitfire.davdroid.App
|
||||
import at.bitfire.davdroid.R
|
||||
|
||||
object NotificationUtils {
|
||||
|
||||
val CHANNEL_DEBUG = "debug"
|
||||
val CHANNEL_SYNC_STATUS = "syncStatus"
|
||||
val CHANNEL_SYNC_PROBLEMS = "syncProblems"
|
||||
// notification IDs
|
||||
const val NOTIFY_EXTERNAL_FILE_LOGGING = 1
|
||||
const val NOTIFY_REFRESH_COLLECTIONS = 2
|
||||
const val NOTIFY_SYNC_ERROR = 10
|
||||
const val NOTIFY_OPENTASKS = 20
|
||||
const val NOTIFY_PERMISSIONS = 21
|
||||
const val NOTIFY_LICENSE = 100
|
||||
|
||||
fun createChannels(context: Context): NotificationManager {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
// notification channels
|
||||
const val CHANNEL_GENERAL = "general"
|
||||
const val CHANNEL_DEBUG = "debug"
|
||||
|
||||
private const val CHANNEL_SYNC = "sync"
|
||||
const val CHANNEL_SYNC_ERRORS = "syncProblems"
|
||||
const val CHANNEL_SYNC_IO_ERRORS = "syncIoErrors"
|
||||
const val CHANNEL_SYNC_STATUS = "syncStatus"
|
||||
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
@TargetApi(Build.VERSION_CODES.O)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val syncChannelGroup = NotificationChannelGroup(CHANNEL_SYNC, context.getString(R.string.notification_channel_sync))
|
||||
nm.createNotificationChannelGroup(syncChannelGroup)
|
||||
val syncChannels = arrayOf(
|
||||
NotificationChannel(CHANNEL_SYNC_ERRORS, context.getString(R.string.notification_channel_sync_errors), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
NotificationChannel(CHANNEL_SYNC_IO_ERRORS, context.getString(R.string.notification_channel_sync_io_errors), NotificationManager.IMPORTANCE_MIN),
|
||||
NotificationChannel(CHANNEL_SYNC_STATUS, context.getString(R.string.notification_channel_sync_status), NotificationManager.IMPORTANCE_MIN)
|
||||
)
|
||||
syncChannels.forEach {
|
||||
it.group = CHANNEL_SYNC
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 26)
|
||||
nm.createNotificationChannels(listOf(
|
||||
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CHANNEL_SYNC_STATUS, context.getString(R.string.notification_channel_sync_status), NotificationManager.IMPORTANCE_LOW),
|
||||
NotificationChannel(CHANNEL_SYNC_PROBLEMS, context.getString(R.string.notification_channel_sync_problems), NotificationManager.IMPORTANCE_DEFAULT)
|
||||
NotificationChannel(CHANNEL_DEBUG, context.getString(R.string.notification_channel_debugging), NotificationManager.IMPORTANCE_HIGH),
|
||||
NotificationChannel(CHANNEL_GENERAL, context.getString(R.string.notification_channel_general), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
*syncChannels
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return nm
|
||||
fun newBuilder(context: Context, channel: String = CHANNEL_GENERAL): NotificationCompat.Builder {
|
||||
val builder = NotificationCompat.Builder(context, channel)
|
||||
.setColor(context.resources.getColor(R.color.primaryColor))
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
|
||||
builder.setLargeIcon(App.getLauncherBitmap(context))
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.ActivityCompat
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.View
|
||||
import at.bitfire.davdroid.Constants
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.ical4android.TaskProvider
|
||||
import kotlinx.android.synthetic.main.activity_permissions.*
|
||||
|
||||
class PermissionsActivity: AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_permissions)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val noCalendarPermissions =
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CALENDAR) != PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CALENDAR) != PackageManager.PERMISSION_GRANTED;
|
||||
calendar_permissions.visibility = if (noCalendarPermissions) View.VISIBLE else View.GONE
|
||||
|
||||
val noContactsPermissions =
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_CONTACTS) != PackageManager.PERMISSION_GRANTED;
|
||||
contacts_permissions.visibility = if (noContactsPermissions) View.VISIBLE else View.GONE
|
||||
|
||||
val noTaskPermissions: Boolean
|
||||
if (LocalTaskList.tasksProviderAvailable(this)) {
|
||||
noTaskPermissions =
|
||||
ActivityCompat.checkSelfPermission(this, TaskProvider.PERMISSION_READ_TASKS) != PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(this, TaskProvider.PERMISSION_WRITE_TASKS) != PackageManager.PERMISSION_GRANTED
|
||||
findViewById<View>(R.id.opentasks_permissions).visibility = if (noTaskPermissions) View.VISIBLE else View.GONE
|
||||
} else {
|
||||
findViewById<View>(R.id.opentasks_permissions).visibility = View.GONE;
|
||||
noTaskPermissions = false
|
||||
}
|
||||
|
||||
if (!noCalendarPermissions && !noContactsPermissions && !noTaskPermissions) {
|
||||
val nm = NotificationUtils.createChannels(this)
|
||||
nm.cancel(Constants.NOTIFICATION_PERMISSIONS)
|
||||
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestCalendarPermissions(v: View) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(
|
||||
Manifest.permission.READ_CALENDAR,
|
||||
Manifest.permission.WRITE_CALENDAR
|
||||
), 0)
|
||||
}
|
||||
|
||||
fun requestContactsPermissions(v: View) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(
|
||||
Manifest.permission.READ_CONTACTS,
|
||||
Manifest.permission.WRITE_CONTACTS
|
||||
), 0)
|
||||
}
|
||||
|
||||
fun requestOpenTasksPermissions(v: View) {
|
||||
ActivityCompat.requestPermissions(this, arrayOf(
|
||||
TaskProvider.PERMISSION_READ_TASKS,
|
||||
TaskProvider.PERMISSION_WRITE_TASKS
|
||||
), 0)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,13 @@
|
||||
|
||||
package at.bitfire.davdroid.ui
|
||||
|
||||
import android.content.*
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import at.bitfire.davdroid.settings.ISettingsObserver
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
@@ -23,33 +27,39 @@ abstract class SettingsLoader<T>(
|
||||
val settingsObserver = object: ISettingsObserver.Stub() {
|
||||
override fun onSettingsChanged() {
|
||||
handler.post {
|
||||
forceLoad()
|
||||
onContentChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsSvc: ServiceConnection? = null
|
||||
var settings: ISettings? = null
|
||||
private val settingsSvc = object: ServiceConnection {
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
|
||||
settings = ISettings.Stub.asInterface(binder)
|
||||
settings!!.registerObserver(settingsObserver)
|
||||
forceLoad()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
settings!!.unregisterObserver(settingsObserver)
|
||||
settings = null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onStartLoading() {
|
||||
context.bindService(Intent(context, Settings::class.java), settingsSvc, Context.BIND_AUTO_CREATE)
|
||||
if (settingsSvc != null)
|
||||
forceLoad()
|
||||
else {
|
||||
settingsSvc = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
|
||||
settings = ISettings.Stub.asInterface(binder)
|
||||
settings!!.registerObserver(settingsObserver)
|
||||
onContentChanged()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
settings!!.unregisterObserver(settingsObserver)
|
||||
settings = null
|
||||
}
|
||||
}
|
||||
context.bindService(Intent(context, Settings::class.java), settingsSvc, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStopLoading() {
|
||||
context.unbindService(settingsSvc)
|
||||
override fun onReset() {
|
||||
settingsSvc?.let {
|
||||
context.unbindService(it)
|
||||
settingsSvc = null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,30 +11,32 @@ package at.bitfire.davdroid.ui
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.app.Dialog
|
||||
import android.app.DialogFragment
|
||||
import android.app.LoaderManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.Loader
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AlertDialog
|
||||
import at.bitfire.davdroid.BuildConfig
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.resource.LocalTaskList
|
||||
import at.bitfire.davdroid.settings.ISettings
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.util.*
|
||||
import java.util.logging.Level
|
||||
|
||||
class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISettings?> {
|
||||
class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISettings> {
|
||||
|
||||
enum class Mode {
|
||||
AUTOSTART_PERMISSIONS,
|
||||
BATTERY_OPTIMIZATIONS,
|
||||
GOOGLE_PLAY_ACCOUNTS_REMOVED,
|
||||
OPENTASKS_NOT_INSTALLED,
|
||||
@@ -43,19 +45,23 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
|
||||
|
||||
companion object {
|
||||
|
||||
private val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup"
|
||||
private const val SETTING_NEXT_DONATION_POPUP = "time_nextDonationPopup"
|
||||
|
||||
@JvmField val HINT_BATTERY_OPTIMIZATIONS = "hint_BatteryOptimizations"
|
||||
@JvmField val HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED = "hint_GooglePlayAccountsRemoved"
|
||||
@JvmField val HINT_OPENTASKS_NOT_INSTALLED = "hint_OpenTasksNotInstalled"
|
||||
const val HINT_AUTOSTART_PERMISSIONS = "hint_AutostartPermissions"
|
||||
// see https://github.com/jaredrummler/AndroidDeviceNames/blob/master/json/ for manufacturer values
|
||||
private val autostartManufacturers = arrayOf("huawei", "letv", "oneplus", "vivo", "xiaomi", "zte")
|
||||
|
||||
val ARGS_MODE = "mode"
|
||||
const val HINT_BATTERY_OPTIMIZATIONS = "hint_BatteryOptimizations"
|
||||
const val HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED = "hint_GooglePlayAccountsRemoved"
|
||||
const val HINT_OPENTASKS_NOT_INSTALLED = "hint_OpenTasksNotInstalled"
|
||||
|
||||
const val ARGS_MODE = "mode"
|
||||
|
||||
fun getStartupDialogs(context: Context, settings: ISettings): List<StartupDialogFragment> {
|
||||
val dialogs = LinkedList<StartupDialogFragment>()
|
||||
|
||||
/* if (System.currentTimeMillis() > settings.getLong(SETTING_NEXT_DONATION_POPUP, 0))
|
||||
dialogs += StartupDialogFragment.instantiate(Mode.OSE_DONATE) */
|
||||
if (System.currentTimeMillis() > settings.getLong(SETTING_NEXT_DONATION_POPUP, 0))
|
||||
dialogs += StartupDialogFragment.instantiate(Mode.OSE_DONATE)
|
||||
|
||||
// store-specific information
|
||||
/*if (BuildConfig.FLAVOR == App.FLAVOR_GOOGLE_PLAY) {
|
||||
@@ -72,6 +78,10 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
|
||||
dialogs.add(StartupDialogFragment.instantiate(Mode.BATTERY_OPTIMIZATIONS))
|
||||
}
|
||||
|
||||
// vendor-specific auto-start information
|
||||
if (autostartManufacturers.contains(Build.MANUFACTURER.toLowerCase()) && settings.getBoolean(HINT_AUTOSTART_PERMISSIONS, true))
|
||||
dialogs.add(StartupDialogFragment.instantiate(Mode.AUTOSTART_PERMISSIONS))
|
||||
|
||||
// OpenTasks information
|
||||
if (!LocalTaskList.tasksProviderAvailable(context) && settings.getBoolean(HINT_OPENTASKS_NOT_INSTALLED, true))
|
||||
dialogs.add(StartupDialogFragment.instantiate(Mode.OPENTASKS_NOT_INSTALLED))
|
||||
@@ -97,14 +107,14 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
|
||||
loaderManager.initLoader(0, null, this)
|
||||
}
|
||||
|
||||
override fun onCreateLoader(code: Int, args: Bundle?): Loader<ISettings?> =
|
||||
SettingsLoader(activity)
|
||||
override fun onCreateLoader(code: Int, args: Bundle?) =
|
||||
SettingsLoader(requireActivity())
|
||||
|
||||
override fun onLoadFinished(loader: Loader<ISettings?>?, result: ISettings?) {
|
||||
override fun onLoadFinished(loader: Loader<ISettings>, result: ISettings?) {
|
||||
settings = result
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<ISettings?>?) {
|
||||
override fun onLoaderReset(loader: Loader<ISettings>) {
|
||||
settings = null
|
||||
}
|
||||
|
||||
@@ -113,20 +123,38 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
isCancelable = false
|
||||
|
||||
val mode = Mode.valueOf(arguments.getString(ARGS_MODE))
|
||||
val activity = requireActivity()
|
||||
val mode = Mode.valueOf(arguments!!.getString(ARGS_MODE))
|
||||
return when (mode) {
|
||||
Mode.AUTOSTART_PERMISSIONS ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setTitle(getString(R.string.startup_autostart_permission, StringUtils.capitalize(Build.MANUFACTURER)))
|
||||
.setMessage(R.string.startup_autostart_permission_message)
|
||||
.setPositiveButton(R.string.startup_more_info, { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url)).buildUpon()
|
||||
.appendPath("faq").appendPath("automatic-synchronization-is-not-run-as-expected").build())
|
||||
if (intent.resolveActivity(activity.packageManager) != null)
|
||||
activity.startActivity(intent)
|
||||
})
|
||||
.setNeutralButton(android.R.string.ok, { _, _ -> })
|
||||
.setNegativeButton(R.string.startup_dont_show_again, { _, _ ->
|
||||
settings?.putBoolean(HINT_AUTOSTART_PERMISSIONS, false)
|
||||
})
|
||||
.create()
|
||||
|
||||
Mode.BATTERY_OPTIMIZATIONS ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setIcon(R.drawable.ic_info_dark)
|
||||
.setTitle(R.string.startup_battery_optimization)
|
||||
.setMessage(R.string.startup_battery_optimization_message)
|
||||
.setPositiveButton(android.R.string.ok, { _, _ -> })
|
||||
.setNeutralButton(R.string.startup_battery_optimization_disable, @TargetApi(Build.VERSION_CODES.M) { _, _ ->
|
||||
.setPositiveButton(R.string.startup_battery_optimization_disable, @TargetApi(Build.VERSION_CODES.M) { _, _ ->
|
||||
val intent = Intent(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:" + BuildConfig.APPLICATION_ID))
|
||||
if (intent.resolveActivity(activity.packageManager) != null)
|
||||
activity.startActivity(intent)
|
||||
})
|
||||
.setNeutralButton(android.R.string.ok, { _, _ -> })
|
||||
.setNegativeButton(R.string.startup_dont_show_again, { _: DialogInterface, _: Int ->
|
||||
settings?.putBoolean(HINT_BATTERY_OPTIMIZATIONS, false)
|
||||
})
|
||||
@@ -143,11 +171,13 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
|
||||
.setIcon(icon)
|
||||
.setTitle(R.string.startup_google_play_accounts_removed)
|
||||
.setMessage(R.string.startup_google_play_accounts_removed_message)
|
||||
.setPositiveButton(android.R.string.ok, { _, _ -> })
|
||||
.setNeutralButton(R.string.startup_google_play_accounts_removed_more_info, { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.navigation_drawer_faq_url)))
|
||||
activity.startActivity(intent)
|
||||
.setPositiveButton(R.string.startup_more_info, { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.homepage_url)).buildUpon()
|
||||
.appendPath("faq").appendPath("accounts-gone-after-reboot-or-update").build())
|
||||
if (intent.resolveActivity(activity.packageManager) != null)
|
||||
activity.startActivity(intent)
|
||||
})
|
||||
.setNeutralButton(android.R.string.ok, { _, _ -> })
|
||||
.setNegativeButton(R.string.startup_dont_show_again, { _, _ ->
|
||||
settings?.putBoolean(HINT_GOOGLE_PLAY_ACCOUNTS_REMOVED, false)
|
||||
})
|
||||
@@ -162,14 +192,14 @@ class StartupDialogFragment: DialogFragment(), LoaderManager.LoaderCallbacks<ISe
|
||||
.setIcon(R.drawable.ic_playlist_add_check_dark)
|
||||
.setTitle(R.string.startup_opentasks_not_installed)
|
||||
.setMessage(builder.toString())
|
||||
.setPositiveButton(android.R.string.ok, { _, _ -> })
|
||||
.setNeutralButton(R.string.startup_opentasks_not_installed_install, { _, _ ->
|
||||
.setPositiveButton(R.string.startup_opentasks_not_installed_install, { _, _ ->
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=org.dmfs.tasks"))
|
||||
if (intent.resolveActivity(activity.packageManager) != null)
|
||||
activity.startActivity(intent)
|
||||
else
|
||||
Logger.log.warning("No market app available, can't install OpenTasks")
|
||||
})
|
||||
.setNeutralButton(android.R.string.ok, { _, _ -> })
|
||||
.setNegativeButton(R.string.startup_dont_show_again, { _: DialogInterface, _: Int ->
|
||||
settings?.putBoolean(HINT_OPENTASKS_NOT_INSTALLED, false)
|
||||
})
|
||||
|
||||
@@ -11,13 +11,17 @@ package at.bitfire.davdroid.ui.setup
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Activity
|
||||
import android.app.Fragment
|
||||
import android.app.LoaderManager
|
||||
import android.content.*
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import android.support.design.widget.Snackbar
|
||||
import android.support.v4.app.Fragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.Loader
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -36,17 +40,15 @@ import java.util.logging.Level
|
||||
class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSettings> {
|
||||
|
||||
companion object {
|
||||
|
||||
val KEY_CONFIG = "config"
|
||||
const val KEY_CONFIG = "config"
|
||||
|
||||
fun newInstance(config: DavResourceFinder.Configuration): AccountDetailsFragment {
|
||||
val frag = AccountDetailsFragment()
|
||||
val args = Bundle(1)
|
||||
args.putSerializable(KEY_CONFIG, config)
|
||||
args.putParcelable(KEY_CONFIG, config)
|
||||
frag.arguments = args
|
||||
return frag
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
var groupMethod: GroupMethod? = null
|
||||
@@ -57,15 +59,15 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
|
||||
val v = inflater.inflate(R.layout.login_account_details, container, false)
|
||||
|
||||
v.back.setOnClickListener({ _ ->
|
||||
fragmentManager.popBackStack()
|
||||
requireFragmentManager().popBackStack()
|
||||
})
|
||||
|
||||
val config = arguments.getSerializable(KEY_CONFIG) as DavResourceFinder.Configuration
|
||||
val args = requireNotNull(arguments)
|
||||
val config = args.getParcelable(KEY_CONFIG) as DavResourceFinder.Configuration
|
||||
|
||||
v.account_name.setText(if (config.calDAV != null && config.calDAV.email != null)
|
||||
config.calDAV.email
|
||||
else
|
||||
config.userName)
|
||||
v.account_name.setText(config.calDAV?.email ?:
|
||||
config.credentials.userName ?:
|
||||
config.credentials.certificateAlias)
|
||||
|
||||
// CardDAV-specific
|
||||
v.carddav.visibility = if (config.cardDAV != null) View.VISIBLE else View.GONE
|
||||
@@ -79,9 +81,9 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
|
||||
if (name.isEmpty())
|
||||
v.account_name.error = getString(R.string.login_account_name_required)
|
||||
else {
|
||||
if (createAccount(name, arguments.getSerializable(KEY_CONFIG) as DavResourceFinder.Configuration)) {
|
||||
activity.setResult(Activity.RESULT_OK)
|
||||
activity.finish()
|
||||
if (createAccount(name, args.getParcelable(KEY_CONFIG) as DavResourceFinder.Configuration)) {
|
||||
requireActivity().setResult(Activity.RESULT_OK)
|
||||
requireActivity().finish()
|
||||
} else
|
||||
Snackbar.make(v, R.string.login_account_not_created, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
@@ -92,28 +94,30 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCreateLoader(code: Int, args: Bundle?): Loader<CreateSettings?> =
|
||||
GroupMethodLoader(activity)
|
||||
override fun onCreateLoader(code: Int, args: Bundle?) =
|
||||
GroupMethodLoader(requireActivity())
|
||||
|
||||
override fun onLoadFinished(loader: Loader<CreateSettings?>?, result: CreateSettings?) {
|
||||
override fun onLoadFinished(loader: Loader<CreateSettings>, result: CreateSettings?) {
|
||||
settings = (result ?: return).settings
|
||||
groupMethod = result.groupMethod
|
||||
|
||||
if (result.groupMethod != null) {
|
||||
view.contact_group_method.isEnabled = false
|
||||
for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) {
|
||||
if (method == result.groupMethod.name) {
|
||||
view.contact_group_method.setSelection(i)
|
||||
break
|
||||
view?.let { view ->
|
||||
if (result.groupMethod != null) {
|
||||
view.contact_group_method.isEnabled = false
|
||||
for ((i, method) in resources.getStringArray(R.array.settings_contact_group_method_values).withIndex()) {
|
||||
if (method == result.groupMethod.name) {
|
||||
view.contact_group_method.setSelection(i)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else
|
||||
view.contact_group_method.isEnabled = true
|
||||
} else
|
||||
view.contact_group_method.isEnabled = true
|
||||
|
||||
view.create_account.isEnabled = true
|
||||
view.create_account.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<CreateSettings?>?) {
|
||||
override fun onLoaderReset(loader: Loader<CreateSettings>) {
|
||||
settings = null
|
||||
groupMethod = null
|
||||
view?.create_account?.isEnabled = false
|
||||
@@ -125,19 +129,19 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
|
||||
val settings = settings ?: return false
|
||||
|
||||
// create Android account
|
||||
val userData = AccountSettings.initialUserData(config.userName)
|
||||
val userData = AccountSettings.initialUserData(config.credentials)
|
||||
Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData))
|
||||
|
||||
val accountManager = AccountManager.get(activity)
|
||||
if (!accountManager.addAccountExplicitly(account, config.password, userData))
|
||||
if (!accountManager.addAccountExplicitly(account, config.credentials.password, userData))
|
||||
return false
|
||||
|
||||
// add entries for account to service DB
|
||||
Logger.log.log(Level.INFO, "Writing account configuration to database", config)
|
||||
OpenHelper(activity).use { dbHelper ->
|
||||
OpenHelper(requireActivity()).use { dbHelper ->
|
||||
val db = dbHelper.writableDatabase
|
||||
try {
|
||||
val accountSettings = AccountSettings(activity, settings, account)
|
||||
val accountSettings = AccountSettings(requireActivity(), settings, account)
|
||||
|
||||
val refreshIntent = Intent(activity, DavService::class.java)
|
||||
refreshIntent.action = DavService.ACTION_REFRESH_COLLECTIONS
|
||||
@@ -148,10 +152,10 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
|
||||
|
||||
// start CardDAV service detection (refresh collections)
|
||||
refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id)
|
||||
activity.startService(refreshIntent)
|
||||
requireActivity().startService(refreshIntent)
|
||||
|
||||
// initial CardDAV account settings
|
||||
val idx = view.contact_group_method.selectedItemPosition
|
||||
val idx = view!!.contact_group_method.selectedItemPosition
|
||||
val groupMethodName = resources.getStringArray(R.array.settings_contact_group_method_values)[idx]
|
||||
accountSettings.setGroupMethod(GroupMethod.valueOf(groupMethodName))
|
||||
|
||||
@@ -166,14 +170,14 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
|
||||
|
||||
// start CalDAV service detection (refresh collections)
|
||||
refreshIntent.putExtra(DavService.EXTRA_DAV_SERVICE_ID, id)
|
||||
activity.startService(refreshIntent)
|
||||
requireActivity().startService(refreshIntent)
|
||||
|
||||
// calendar sync is automatically enabled by isAlwaysSyncable="true" in res/xml/sync_contacts.xml
|
||||
accountSettings.setSyncInterval(CalendarContract.AUTHORITY, Constants.DEFAULT_SYNC_INTERVAL)
|
||||
|
||||
// enable task sync if OpenTasks is installed
|
||||
// further changes will be handled by PackageChangedReceiver
|
||||
if (LocalTaskList.tasksProviderAvailable(activity)) {
|
||||
if (LocalTaskList.tasksProviderAvailable(requireActivity())) {
|
||||
ContentResolver.setIsSyncable(account, TaskProvider.ProviderName.OpenTasks.authority, 1)
|
||||
accountSettings.setSyncInterval(TaskProvider.ProviderName.OpenTasks.authority, Constants.DEFAULT_SYNC_INTERVAL)
|
||||
}
|
||||
@@ -222,7 +226,7 @@ class AccountDetailsFragment: Fragment(), LoaderManager.LoaderCallbacks<CreateSe
|
||||
|
||||
class GroupMethodLoader(
|
||||
context: Context
|
||||
): SettingsLoader<CreateSettings?>(context) {
|
||||
): SettingsLoader<CreateSettings>(context) {
|
||||
|
||||
override fun loadInBackground(): CreateSettings? {
|
||||
settings?.let { settings ->
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import at.bitfire.dav4android.DavResource
|
||||
import at.bitfire.dav4android.DavResponse
|
||||
import at.bitfire.dav4android.UrlUtils
|
||||
import at.bitfire.dav4android.exception.DavException
|
||||
import at.bitfire.dav4android.exception.HttpException
|
||||
@@ -17,15 +20,13 @@ import at.bitfire.davdroid.DavUtils
|
||||
import at.bitfire.davdroid.HttpClient
|
||||
import at.bitfire.davdroid.log.StringHandler
|
||||
import at.bitfire.davdroid.model.CollectionInfo
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import at.bitfire.davdroid.settings.Settings
|
||||
import okhttp3.HttpUrl
|
||||
import org.apache.commons.lang3.builder.ReflectionToStringBuilder
|
||||
import org.apache.commons.lang3.builder.ToStringBuilder
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.Type
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.util.*
|
||||
@@ -34,8 +35,8 @@ import java.util.logging.Logger
|
||||
|
||||
class DavResourceFinder(
|
||||
val context: Context,
|
||||
private val credentials: LoginCredentials
|
||||
): Closeable {
|
||||
private val loginInfo: LoginInfo
|
||||
): AutoCloseable {
|
||||
|
||||
enum class Service(val wellKnownName: String) {
|
||||
CALDAV("caldav"),
|
||||
@@ -53,13 +54,13 @@ class DavResourceFinder(
|
||||
|
||||
private val settings = Settings.getInstance(context)
|
||||
private val httpClient: HttpClient = HttpClient.Builder(context, settings, logger = log)
|
||||
.addAuthentication(null, credentials.userName, credentials.password)
|
||||
.addAuthentication(null, loginInfo.credentials)
|
||||
.setForeground(true)
|
||||
.build()
|
||||
|
||||
fun cancel() {
|
||||
log.warning("Shutting down resource detection")
|
||||
httpClient.okHttpClient.dispatcher().executorService().shutdownNow()
|
||||
httpClient.okHttpClient.dispatcher().executorService().shutdown()
|
||||
httpClient.okHttpClient.connectionPool().evictAll()
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ class DavResourceFinder(
|
||||
val calDavConfig = findInitialConfiguration(Service.CALDAV)
|
||||
|
||||
return Configuration(
|
||||
credentials.userName, credentials.password,
|
||||
loginInfo.credentials,
|
||||
cardDavConfig, calDavConfig,
|
||||
logBuffer.toString()
|
||||
)
|
||||
@@ -82,7 +83,7 @@ class DavResourceFinder(
|
||||
|
||||
private fun findInitialConfiguration(service: Service): Configuration.ServiceInfo? {
|
||||
// user-given base URI (either mailto: URI or http(s):// URL)
|
||||
val baseURI = credentials.uri
|
||||
val baseURI = loginInfo.uri
|
||||
|
||||
// domain for service discovery
|
||||
var discoveryFQDN: String? = null
|
||||
@@ -128,18 +129,19 @@ class DavResourceFinder(
|
||||
|
||||
if (config.principal != null && service == Service.CALDAV) {
|
||||
// query email address (CalDAV scheduling: calendar-user-address-set)
|
||||
val davPrincipal = DavResource(httpClient.okHttpClient, HttpUrl.get(config.principal)!!, log)
|
||||
val davPrincipal = DavResource(httpClient.okHttpClient, config.principal!!, log)
|
||||
try {
|
||||
davPrincipal.propfind(0, CalendarUserAddressSet.NAME)
|
||||
(davPrincipal.properties[CalendarUserAddressSet.NAME] as CalendarUserAddressSet?)?.let { addressSet ->
|
||||
for (href in addressSet.hrefs)
|
||||
try {
|
||||
val uri = URI(href)
|
||||
if (uri.scheme.equals("mailto", true))
|
||||
config.email = uri.schemeSpecificPart
|
||||
} catch(e: URISyntaxException) {
|
||||
log.log(Level.WARNING, "Unparseable user address", e)
|
||||
}
|
||||
davPrincipal.propfind(0, CalendarUserAddressSet.NAME).use { response ->
|
||||
response[CalendarUserAddressSet::class.java]?.let { addressSet ->
|
||||
for (href in addressSet.hrefs)
|
||||
try {
|
||||
val uri = URI(href)
|
||||
if (uri.scheme.equals("mailto", true))
|
||||
config.email = uri.schemeSpecificPart
|
||||
} catch(e: URISyntaxException) {
|
||||
log.log(Level.WARNING, "Couldn't parse user address", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.WARNING, "Couldn't query user email address", e)
|
||||
@@ -161,47 +163,50 @@ class DavResourceFinder(
|
||||
try {
|
||||
val davBase = DavResource(httpClient.okHttpClient, baseURL, log)
|
||||
|
||||
when (service) {
|
||||
Service.CARDDAV -> {
|
||||
davBase.propfind(0,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||||
AddressbookHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
)
|
||||
rememberIfAddressBookOrHomeset(davBase, config)
|
||||
}
|
||||
Service.CALDAV -> {
|
||||
davBase.propfind(0,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
)
|
||||
rememberIfCalendarOrHomeset(davBase, config)
|
||||
}
|
||||
}
|
||||
|
||||
// check for current-user-principal
|
||||
davBase.findProperty(CurrentUserPrincipal.NAME)?.let { (dav, second) ->
|
||||
val currentUserPrincipal = second as CurrentUserPrincipal?
|
||||
currentUserPrincipal?.href?.let {
|
||||
principal = dav.location.resolve(it)
|
||||
}
|
||||
}
|
||||
|
||||
// check for resource type "principal"
|
||||
if (principal == null)
|
||||
for ((dav, second) in davBase.findProperties(ResourceType.NAME)) {
|
||||
val resourceType = second as ResourceType?
|
||||
if (resourceType != null && resourceType.types.contains(ResourceType.PRINCIPAL)) {
|
||||
principal = dav.location
|
||||
break
|
||||
lateinit var response: DavResponse
|
||||
try {
|
||||
when (service) {
|
||||
Service.CARDDAV -> {
|
||||
response = davBase.propfind(0,
|
||||
ResourceType.NAME, DisplayName.NAME, AddressbookDescription.NAME,
|
||||
AddressbookHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
)
|
||||
rememberIfAddressBookOrHomeset(response, config)
|
||||
}
|
||||
Service.CALDAV -> {
|
||||
response = davBase.propfind(0,
|
||||
ResourceType.NAME, DisplayName.NAME, CalendarColor.NAME, CalendarDescription.NAME, CalendarTimezone.NAME, CurrentUserPrivilegeSet.NAME, SupportedCalendarComponentSet.NAME,
|
||||
CalendarHomeSet.NAME,
|
||||
CurrentUserPrincipal.NAME
|
||||
)
|
||||
rememberIfCalendarOrHomeset(response, config)
|
||||
}
|
||||
}
|
||||
|
||||
// check for current-user-principal
|
||||
response.searchProperty(CurrentUserPrincipal::class.java)?.let { (dav, currentUserPrincipal) ->
|
||||
currentUserPrincipal.href?.let {
|
||||
principal = dav.url.resolve(it)
|
||||
}
|
||||
}
|
||||
|
||||
// check for resource type "principal"
|
||||
if (principal == null)
|
||||
for ((dav, resourceType) in response.searchProperties(ResourceType::class.java)) {
|
||||
if (resourceType.types.contains(ResourceType.PRINCIPAL)) {
|
||||
principal = dav.url
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
response.close()
|
||||
}
|
||||
|
||||
// If a principal has been detected successfully, ensure that it provides the required service.
|
||||
principal?.let {
|
||||
if (providesService(it, service))
|
||||
config.principal = it.uri()
|
||||
config.principal = it
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.FINE, "PROPFIND/OPTIONS on user-given URL failed", e)
|
||||
@@ -209,55 +214,50 @@ class DavResourceFinder(
|
||||
}
|
||||
|
||||
/**
|
||||
* If #dav references an address book or an address book home set, it will added to
|
||||
* config.collections or config.homesets. Only evaluates already known properties,
|
||||
* does not call dav.propfind()! URLs will be stored with trailing "/".
|
||||
* If [dav] references an address book or an address book home set, it will added to
|
||||
* config.collections or config.homesets. URLs will be stored with trailing "/".
|
||||
* @param dav resource whose properties are evaluated
|
||||
* @param config structure where the address book (collection) and/or home set is stored into (if found)
|
||||
*/
|
||||
fun rememberIfAddressBookOrHomeset(dav: DavResource, config: Configuration.ServiceInfo) {
|
||||
fun rememberIfAddressBookOrHomeset(dav: DavResponse, config: Configuration.ServiceInfo) {
|
||||
// Is there an address book?
|
||||
for ((addressBook, second) in dav.findProperties(ResourceType.NAME)) {
|
||||
val resourceType = second as ResourceType
|
||||
for ((addressBook, resourceType) in dav.searchProperties(ResourceType::class.java)) {
|
||||
if (resourceType.types.contains(ResourceType.ADDRESSBOOK)) {
|
||||
addressBook.location = UrlUtils.withTrailingSlash(addressBook.location)
|
||||
log.info("Found address book at ${addressBook.location}")
|
||||
config.collections.put(addressBook.location.uri(), CollectionInfo(addressBook))
|
||||
val info = CollectionInfo(addressBook)
|
||||
log.info("Found address book at ${info.url}")
|
||||
config.collections[info.url] = info
|
||||
}
|
||||
}
|
||||
|
||||
// Is there an addressbook-home-set?
|
||||
for ((dav, second) in dav.findProperties(AddressbookHomeSet.NAME)) {
|
||||
val homeSet = second as AddressbookHomeSet
|
||||
for ((dav, homeSet) in dav.searchProperties(AddressbookHomeSet::class.java)) {
|
||||
for (href in homeSet.hrefs) {
|
||||
dav.location.resolve(href)?.let {
|
||||
dav.url.resolve(href)?.let {
|
||||
val location = UrlUtils.withTrailingSlash(it)
|
||||
log.info("Found address book home-set at $location")
|
||||
config.homeSets.add(location.uri())
|
||||
config.homeSets += location
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rememberIfCalendarOrHomeset(dav: DavResource, config: Configuration.ServiceInfo) {
|
||||
private fun rememberIfCalendarOrHomeset(dav: DavResponse, config: Configuration.ServiceInfo) {
|
||||
// Is the collection a calendar collection?
|
||||
for ((calendar, second) in dav.findProperties(ResourceType.NAME)) {
|
||||
val resourceType = second as ResourceType
|
||||
for ((calendar, resourceType) in dav.searchProperties(ResourceType::class.java)) {
|
||||
if (resourceType.types.contains(ResourceType.CALENDAR)) {
|
||||
calendar.location = UrlUtils.withTrailingSlash(calendar.location)
|
||||
log.info("Found calendar at ${calendar.location}")
|
||||
config.collections.put(calendar.location.uri(), CollectionInfo(calendar))
|
||||
val info = CollectionInfo(calendar)
|
||||
log.info("Found calendar at ${info.url}")
|
||||
config.collections[info.url] = info
|
||||
}
|
||||
}
|
||||
|
||||
// Is there an calendar-home-set?
|
||||
for ((dav, second) in dav.findProperties(CalendarHomeSet.NAME)) {
|
||||
val homeSet = second as CalendarHomeSet
|
||||
for ((dav, homeSet) in dav.searchProperties(CalendarHomeSet::class.java)) {
|
||||
for (href in homeSet.hrefs) {
|
||||
dav.location.resolve(href)?.let {
|
||||
dav.url.resolve(href)?.let {
|
||||
val location = UrlUtils.withTrailingSlash(it)
|
||||
log.info("Found calendar home-set at $location")
|
||||
config.homeSets.add(location.uri())
|
||||
config.homeSets += location
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,14 +268,14 @@ class DavResourceFinder(
|
||||
fun providesService(url: HttpUrl, service: Service): Boolean {
|
||||
val davPrincipal = DavResource(httpClient.okHttpClient, url, log)
|
||||
try {
|
||||
davPrincipal.options()
|
||||
|
||||
if ((service == Service.CARDDAV && davPrincipal.capabilities.contains("addressbook")) ||
|
||||
(service == Service.CALDAV && davPrincipal.capabilities.contains("calendar-access")))
|
||||
return true
|
||||
|
||||
davPrincipal.options().use {
|
||||
val capabilities = it.capabilities
|
||||
if ((service == Service.CARDDAV && capabilities.contains("addressbook")) ||
|
||||
(service == Service.CALDAV && capabilities.contains("calendar-access")))
|
||||
return true
|
||||
}
|
||||
} catch(e: Exception) {
|
||||
log.log(Level.SEVERE, "Couldn't detect services on " + url, e)
|
||||
log.log(Level.SEVERE, "Couldn't detect services on $url", e)
|
||||
if (e !is HttpException && e !is DavException)
|
||||
throw e
|
||||
}
|
||||
@@ -291,7 +291,7 @@ class DavResourceFinder(
|
||||
* @return principal URL, or null if none found
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
private fun discoverPrincipalUrl(domain: String, service: Service): URI? {
|
||||
private fun discoverPrincipalUrl(domain: String, service: Service): HttpUrl? {
|
||||
val scheme: String
|
||||
val fqdn: String
|
||||
var port = 443
|
||||
@@ -299,7 +299,9 @@ class DavResourceFinder(
|
||||
|
||||
val query = "_${service.wellKnownName}s._tcp.$domain"
|
||||
log.fine("Looking up SRV records for $query")
|
||||
val srv = DavUtils.selectSRVRecord(Lookup(query, Type.SRV).run())
|
||||
val srvLookup = Lookup(query, Type.SRV)
|
||||
DavUtils.prepareLookup(context, srvLookup)
|
||||
val srv = DavUtils.selectSRVRecord(srvLookup.run())
|
||||
if (srv != null) {
|
||||
// choose SRV record to use (query may return multiple SRV records)
|
||||
scheme = "https"
|
||||
@@ -315,7 +317,9 @@ class DavResourceFinder(
|
||||
}
|
||||
|
||||
// look for TXT record too (for initial context path)
|
||||
paths.addAll(DavUtils.pathsFromTXTRecords(Lookup(query, Type.TXT).run()))
|
||||
val txtLookup = Lookup(query, Type.TXT)
|
||||
DavUtils.prepareLookup(context, txtLookup)
|
||||
paths.addAll(DavUtils.pathsFromTXTRecords(txtLookup.run()))
|
||||
|
||||
// if there's TXT record and if it it's wrong, try well-known
|
||||
paths.add("/.well-known/" + service.wellKnownName)
|
||||
@@ -347,23 +351,22 @@ class DavResourceFinder(
|
||||
* @return current-user-principal URL that provides required service, or null if none
|
||||
*/
|
||||
@Throws(IOException::class, HttpException::class, DavException::class)
|
||||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): URI? {
|
||||
fun getCurrentUserPrincipal(url: HttpUrl, service: Service?): HttpUrl? {
|
||||
val dav = DavResource(httpClient.okHttpClient, url, log)
|
||||
dav.propfind(0, CurrentUserPrincipal.NAME)
|
||||
dav.propfind(0, CurrentUserPrincipal.NAME).use {
|
||||
it.searchProperty(CurrentUserPrincipal::class.java)?.let { (dav, currentUserPrincipal) ->
|
||||
currentUserPrincipal.href?.let { href ->
|
||||
dav.url.resolve(href)?.let { principal ->
|
||||
log.info("Found current-user-principal: $principal")
|
||||
|
||||
dav.findProperty(CurrentUserPrincipal.NAME)?.let { (dav, second) ->
|
||||
val currentUserPrincipal = second as CurrentUserPrincipal
|
||||
currentUserPrincipal.href?.let { href ->
|
||||
dav.location.resolve(href)?.let { principal ->
|
||||
log.info("Found current-user-principal: $principal")
|
||||
// service check
|
||||
if (service != null && !providesService(principal, service)) {
|
||||
log.info("$principal doesn't provide required $service service")
|
||||
return null
|
||||
}
|
||||
|
||||
// service check
|
||||
if (service != null && !providesService(principal, service)) {
|
||||
log.info("$principal doesn't provide required $service service")
|
||||
return null
|
||||
return principal
|
||||
}
|
||||
|
||||
return principal.uri()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -374,25 +377,21 @@ class DavResourceFinder(
|
||||
// data classes
|
||||
|
||||
class Configuration(
|
||||
val userName: String,
|
||||
val password: String,
|
||||
val credentials: Credentials,
|
||||
|
||||
val cardDAV: ServiceInfo?,
|
||||
val calDAV: ServiceInfo?,
|
||||
|
||||
val logs: String
|
||||
): Serializable {
|
||||
// We have to use URI here because HttpUrl is not serializable!
|
||||
): Parcelable {
|
||||
|
||||
class ServiceInfo: Serializable {
|
||||
var principal: URI? = null
|
||||
val homeSets = HashSet<URI>()
|
||||
val collections = HashMap<URI, CollectionInfo>()
|
||||
data class ServiceInfo(
|
||||
var principal: HttpUrl? = null,
|
||||
val homeSets: MutableSet<HttpUrl> = HashSet(),
|
||||
val collections: MutableMap<HttpUrl, CollectionInfo> = HashMap(),
|
||||
|
||||
var email: String? = null
|
||||
|
||||
override fun toString() = ToStringBuilder.reflectionToString(this)!!
|
||||
}
|
||||
var email: String? = null
|
||||
)
|
||||
|
||||
override fun toString(): String {
|
||||
val builder = ReflectionToStringBuilder(this)
|
||||
@@ -400,6 +399,74 @@ class DavResourceFinder(
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
fun writeServiceInfo(info: ServiceInfo?) {
|
||||
if (info == null)
|
||||
dest.writeByte(0)
|
||||
else {
|
||||
dest.writeByte(1)
|
||||
dest.writeString(info.principal?.toString())
|
||||
|
||||
dest.writeInt(info.homeSets.size)
|
||||
info.homeSets.forEach { dest.writeString(it.toString()) }
|
||||
|
||||
dest.writeInt(info.collections.size)
|
||||
info.collections.forEach { url, collectionInfo ->
|
||||
dest.writeString(url.toString())
|
||||
dest.writeParcelable(collectionInfo, 0)
|
||||
}
|
||||
|
||||
dest.writeString(info.email)
|
||||
}
|
||||
}
|
||||
|
||||
dest.writeSerializable(credentials)
|
||||
writeServiceInfo(cardDAV)
|
||||
writeServiceInfo(calDAV)
|
||||
dest.writeString(logs)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
@JvmField
|
||||
val CREATOR = object: Parcelable.Creator<Configuration> {
|
||||
|
||||
override fun createFromParcel(source: Parcel): Configuration {
|
||||
fun readCollections(): MutableMap<HttpUrl, CollectionInfo> {
|
||||
val size = source.readInt()
|
||||
val map = HashMap<HttpUrl, CollectionInfo>(size)
|
||||
(1..size).forEach {
|
||||
val url = HttpUrl.parse(source.readString())!!
|
||||
map[url] = source.readParcelable(Thread.currentThread().contextClassLoader)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
fun readServiceInfo(): ServiceInfo? {
|
||||
return if (source.readByte() == 0.toByte())
|
||||
null
|
||||
else
|
||||
ServiceInfo(
|
||||
source.readString()?.let { HttpUrl.parse(it) },
|
||||
(1..source.readInt()).map { HttpUrl.parse(source.readString())!! }.toMutableSet(),
|
||||
readCollections()
|
||||
)
|
||||
}
|
||||
|
||||
return Configuration(
|
||||
source.readSerializable() as Credentials,
|
||||
readServiceInfo(),
|
||||
readServiceInfo(),
|
||||
source.readString()
|
||||
)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int) = arrayOfNulls<Configuration>(size)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+123
-77
@@ -8,9 +8,12 @@
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.app.Fragment
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.security.KeyChain
|
||||
import android.support.v4.app.Fragment
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -37,10 +40,10 @@ class DefaultLoginCredentialsFragment: Fragment(), CompoundButton.OnCheckedChang
|
||||
val password = it.getStringExtra(LoginActivity.EXTRA_PASSWORD)
|
||||
|
||||
if (url != null) {
|
||||
v.login_type_url.isChecked = true
|
||||
v.base_url.setText(url)
|
||||
v.user_name.setText(username)
|
||||
v.url_password.setText(password)
|
||||
v.login_type_urlpwd.isChecked = true
|
||||
v.urlpwd_base_url.setText(url)
|
||||
v.urlpwd_user_name.setText(username)
|
||||
v.urlpwd_password.setText(password)
|
||||
} else {
|
||||
v.login_type_email.isChecked = true
|
||||
v.email_address.setText(username)
|
||||
@@ -49,110 +52,153 @@ class DefaultLoginCredentialsFragment: Fragment(), CompoundButton.OnCheckedChang
|
||||
}
|
||||
}
|
||||
|
||||
v.login.setOnClickListener({ _ ->
|
||||
validateLoginData()?.let { credentials ->
|
||||
DetectConfigurationFragment.newInstance(credentials).show(fragmentManager, null)
|
||||
v.urlcert_select_cert.setOnClickListener {
|
||||
KeyChain.choosePrivateKeyAlias(activity, { alias ->
|
||||
Handler(Looper.getMainLooper()).post({
|
||||
v.urlcert_cert_alias.text = alias
|
||||
v.urlcert_cert_alias.error = null
|
||||
})
|
||||
}, null, null, null, -1, view!!.urlcert_cert_alias.text.toString())
|
||||
}
|
||||
|
||||
v.login.setOnClickListener {
|
||||
validateLoginData()?.let { info ->
|
||||
DetectConfigurationFragment.newInstance(info).show(fragmentManager, null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// initialize to Login by email
|
||||
onCheckedChanged(v)
|
||||
|
||||
v.login_type_email.setOnCheckedChangeListener(this)
|
||||
v.login_type_url.setOnCheckedChangeListener(this)
|
||||
v.login_type_urlpwd.setOnCheckedChangeListener(this)
|
||||
v.login_type_urlcert.setOnCheckedChangeListener(this)
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||
onCheckedChanged(view)
|
||||
onCheckedChanged(view!!)
|
||||
}
|
||||
|
||||
private fun onCheckedChanged(v: View) {
|
||||
val loginByEmail = !v.login_type_url.isChecked
|
||||
v.login_type_email_details.visibility = if (loginByEmail) View.VISIBLE else View.GONE
|
||||
v.login_type_url_details.visibility = if (loginByEmail) View.GONE else View.VISIBLE
|
||||
(if (loginByEmail) v.email_address else v.base_url).requestFocus()
|
||||
v.login_type_email_details.visibility = if (v.login_type_email.isChecked) View.VISIBLE else View.GONE
|
||||
v.login_type_urlpwd_details.visibility = if (v.login_type_urlpwd.isChecked) View.VISIBLE else View.GONE
|
||||
v.login_type_urlcert_details.visibility = if (v.login_type_urlcert.isChecked) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun validateLoginData(): LoginCredentials? {
|
||||
if (view.login_type_email.isChecked) {
|
||||
var uri: URI? = null
|
||||
var valid = true
|
||||
private fun validateLoginData(): LoginInfo? {
|
||||
val view = requireNotNull(view)
|
||||
when {
|
||||
// Login with email address
|
||||
view.login_type_email.isChecked -> {
|
||||
var uri: URI? = null
|
||||
var valid = true
|
||||
|
||||
val email = view.email_address.text.toString()
|
||||
if (!email.matches(Regex(".+@.+"))) {
|
||||
view.email_address.error = getString(R.string.login_email_address_error)
|
||||
valid = false
|
||||
} else
|
||||
try {
|
||||
uri = URI("mailto", email, null)
|
||||
} catch(e: URISyntaxException) {
|
||||
view.email_address.error = e.localizedMessage
|
||||
valid = false
|
||||
}
|
||||
|
||||
val password = view.email_password.getText().toString()
|
||||
if (password.isEmpty()) {
|
||||
view.email_password.setError(getString(R.string.login_password_required))
|
||||
valid = false
|
||||
}
|
||||
|
||||
return if (valid && uri != null)
|
||||
LoginCredentials(uri, email, password)
|
||||
else
|
||||
null
|
||||
|
||||
} else if (view.login_type_url.isChecked) {
|
||||
var uri: URI? = null
|
||||
var valid = true
|
||||
|
||||
val baseUrl = Uri.parse(view.base_url.text.toString())
|
||||
val scheme = baseUrl.scheme
|
||||
if (scheme.equals("http", true) || scheme.equals("https", true)) {
|
||||
var host = baseUrl.host
|
||||
if (host.isNullOrBlank()) {
|
||||
view.base_url.error = getString(R.string.login_url_host_name_required)
|
||||
val email = view.email_address.text.toString()
|
||||
if (!email.matches(Regex(".+@.+"))) {
|
||||
view.email_address.error = getString(R.string.login_email_address_error)
|
||||
valid = false
|
||||
} else
|
||||
try {
|
||||
host = IDN.toASCII(host)
|
||||
} catch(e: IllegalArgumentException) {
|
||||
Constants.log.log(Level.WARNING, "Host name not conforming to RFC 3490", e)
|
||||
uri = URI("mailto", email, null)
|
||||
} catch (e: URISyntaxException) {
|
||||
view.email_address.error = e.localizedMessage
|
||||
valid = false
|
||||
}
|
||||
|
||||
val path = baseUrl.encodedPath
|
||||
val port = baseUrl.port
|
||||
try {
|
||||
uri = URI(baseUrl.scheme, null, host, port, path, null, null)
|
||||
} catch(e: URISyntaxException) {
|
||||
view.base_url.error = e.localizedMessage
|
||||
val password = view.email_password.text.toString()
|
||||
if (password.isEmpty()) {
|
||||
view.email_password.error = getString(R.string.login_password_required)
|
||||
valid = false
|
||||
}
|
||||
} else {
|
||||
view.base_url.error = getString(R.string.login_url_must_be_http_or_https)
|
||||
valid = false
|
||||
|
||||
return if (valid && uri != null)
|
||||
LoginInfo(uri, email, password)
|
||||
else
|
||||
null
|
||||
|
||||
}
|
||||
|
||||
val userName = view.user_name.text.toString()
|
||||
if (userName.isBlank()) {
|
||||
view.user_name.error = getString(R.string.login_user_name_required)
|
||||
valid = false
|
||||
// Login with URL and user name
|
||||
view.login_type_urlpwd.isChecked -> {
|
||||
var valid = true
|
||||
|
||||
val baseUrl = Uri.parse(view.urlpwd_base_url.text.toString())
|
||||
val uri = validateBaseUrl(baseUrl, false, { message ->
|
||||
view.urlpwd_base_url.error = message
|
||||
valid = false
|
||||
})
|
||||
|
||||
val userName = view.urlpwd_user_name.text.toString()
|
||||
if (userName.isBlank()) {
|
||||
view.urlpwd_user_name.error = getString(R.string.login_user_name_required)
|
||||
valid = false
|
||||
}
|
||||
|
||||
val password = view.urlpwd_password.text.toString()
|
||||
if (password.isEmpty()) {
|
||||
view.urlpwd_password.error = getString(R.string.login_password_required)
|
||||
valid = false
|
||||
}
|
||||
|
||||
return if (valid && uri != null)
|
||||
LoginInfo(uri, userName, password)
|
||||
else
|
||||
null
|
||||
}
|
||||
|
||||
val password = view.url_password.getText().toString()
|
||||
if (password.isEmpty()) {
|
||||
view.url_password.setError(getString(R.string.login_password_required))
|
||||
valid = false
|
||||
}
|
||||
// Login with URL and client certificate
|
||||
view.login_type_urlcert.isChecked -> {
|
||||
var valid = true
|
||||
|
||||
return if (valid && uri != null)
|
||||
LoginCredentials(uri, userName, password)
|
||||
else
|
||||
null
|
||||
val baseUrl = Uri.parse(view.urlcert_base_url.text.toString())
|
||||
val uri = validateBaseUrl(baseUrl, true, { message ->
|
||||
view.urlcert_base_url.error = message
|
||||
valid = false
|
||||
})
|
||||
|
||||
val alias = view.urlcert_cert_alias.text.toString()
|
||||
if (alias.isEmpty()) {
|
||||
view.urlcert_cert_alias.error = ""
|
||||
valid = false
|
||||
}
|
||||
|
||||
if (valid && uri != null)
|
||||
return LoginInfo(uri, certificateAlias = alias)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun validateBaseUrl(baseUrl: Uri, httpsRequired: Boolean, reportError: (String) -> Unit): URI? {
|
||||
var uri: URI? = null
|
||||
val scheme = baseUrl.scheme
|
||||
if ((!httpsRequired && scheme.equals("http", true)) || scheme.equals("https", true)) {
|
||||
var host = baseUrl.host
|
||||
if (host.isNullOrBlank())
|
||||
reportError(getString(R.string.login_url_host_name_required))
|
||||
else
|
||||
try {
|
||||
host = IDN.toASCII(host)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Constants.log.log(Level.WARNING, "Host name not conforming to RFC 3490", e)
|
||||
}
|
||||
|
||||
val path = baseUrl.encodedPath
|
||||
val port = baseUrl.port
|
||||
try {
|
||||
uri = URI(baseUrl.scheme, null, host, port, path, null, null)
|
||||
} catch (e: URISyntaxException) {
|
||||
reportError(e.localizedMessage)
|
||||
}
|
||||
} else
|
||||
reportError(getString(if (httpsRequired)
|
||||
R.string.login_url_must_be_https
|
||||
else
|
||||
R.string.login_url_must_be_http_or_https))
|
||||
return uri
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.app.Dialog
|
||||
import android.app.ProgressDialog
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v4.app.DialogFragment
|
||||
import android.support.v4.app.LoaderManager
|
||||
import android.support.v4.content.AsyncTaskLoader
|
||||
import android.support.v4.content.Loader
|
||||
import android.support.v7.app.AlertDialog
|
||||
import at.bitfire.davdroid.R
|
||||
import at.bitfire.davdroid.log.Logger
|
||||
import at.bitfire.davdroid.ui.DebugInfoActivity
|
||||
@@ -21,9 +29,9 @@ import kotlin.concurrent.thread
|
||||
class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbacks<Configuration> {
|
||||
|
||||
companion object {
|
||||
val ARG_LOGIN_CREDENTIALS = "credentials"
|
||||
const val ARG_LOGIN_CREDENTIALS = "credentials"
|
||||
|
||||
fun newInstance(credentials: LoginCredentials): DetectConfigurationFragment {
|
||||
fun newInstance(credentials: LoginInfo): DetectConfigurationFragment {
|
||||
val frag = DetectConfigurationFragment()
|
||||
val args = Bundle(1)
|
||||
args.putParcelable(ARG_LOGIN_CREDENTIALS, credentials)
|
||||
@@ -53,25 +61,25 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
|
||||
}
|
||||
|
||||
|
||||
override fun onCreateLoader(id: Int, args: Bundle) =
|
||||
ServerConfigurationLoader(activity, args.getParcelable(ARG_LOGIN_CREDENTIALS))
|
||||
override fun onCreateLoader(id: Int, args: Bundle?) =
|
||||
ServerConfigurationLoader(requireActivity(), args!!.getParcelable(ARG_LOGIN_CREDENTIALS))
|
||||
|
||||
override fun onLoadFinished(loader: Loader<Configuration>, data: Configuration?) {
|
||||
data?.let {
|
||||
if (it.calDAV == null && it.cardDAV == null)
|
||||
// no service found: show error message
|
||||
fragmentManager.beginTransaction()
|
||||
requireFragmentManager().beginTransaction()
|
||||
.add(NothingDetectedFragment.newInstance(it.logs), null)
|
||||
.commitAllowingStateLoss()
|
||||
.commit()
|
||||
else
|
||||
// service found: continue
|
||||
fragmentManager.beginTransaction()
|
||||
requireFragmentManager().beginTransaction()
|
||||
.replace(android.R.id.content, AccountDetailsFragment.newInstance(data))
|
||||
.addToBackStack(null)
|
||||
.commitAllowingStateLoss()
|
||||
.commit()
|
||||
}
|
||||
|
||||
dismissAllowingStateLoss()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override fun onLoaderReset(loader: Loader<Configuration>) {}
|
||||
@@ -80,7 +88,7 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
|
||||
class NothingDetectedFragment: DialogFragment() {
|
||||
|
||||
companion object {
|
||||
val KEY_LOGS = "logs"
|
||||
const val KEY_LOGS = "logs"
|
||||
|
||||
fun newInstance(logs: String): NothingDetectedFragment {
|
||||
val args = Bundle()
|
||||
@@ -92,13 +100,13 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?) =
|
||||
AlertDialog.Builder(activity)
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setTitle(R.string.login_configuration_detection)
|
||||
.setIcon(R.drawable.ic_error_dark)
|
||||
.setMessage(R.string.login_no_caldav_carddav)
|
||||
.setNeutralButton(R.string.login_view_logs, { _, _ ->
|
||||
val intent = Intent(activity, DebugInfoActivity::class.java)
|
||||
intent.putExtra(DebugInfoActivity.KEY_LOGS, arguments.getString(KEY_LOGS))
|
||||
intent.putExtra(DebugInfoActivity.KEY_LOGS, arguments!!.getString(KEY_LOGS))
|
||||
startActivity(intent)
|
||||
})
|
||||
.setPositiveButton(android.R.string.ok, { _, _ ->
|
||||
@@ -111,18 +119,18 @@ class DetectConfigurationFragment: DialogFragment(), LoaderManager.LoaderCallbac
|
||||
|
||||
class ServerConfigurationLoader(
|
||||
context: Context,
|
||||
val credentials: LoginCredentials
|
||||
private val credentials: LoginInfo
|
||||
): AsyncTaskLoader<Configuration>(context) {
|
||||
|
||||
var resourceFinder: DavResourceFinder? = null
|
||||
private var resourceFinder: DavResourceFinder? = null
|
||||
|
||||
override fun onStartLoading() = forceLoad()
|
||||
|
||||
override fun onCancelLoad(): Boolean {
|
||||
override fun cancelLoadInBackground() {
|
||||
thread {
|
||||
resourceFinder?.cancel()
|
||||
resourceFinder = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun loadInBackground(): Configuration {
|
||||
|
||||
@@ -28,21 +28,18 @@ class LoginActivity: AppCompatActivity() {
|
||||
* When set, "login by URL" will be activated by default, and the URL field will be set to this value.
|
||||
* When not set, "login by email" will be activated by default.
|
||||
*/
|
||||
@JvmField
|
||||
val EXTRA_URL = "url"
|
||||
const val EXTRA_URL = "url"
|
||||
|
||||
/**
|
||||
* When set, and {@link #EXTRA_PASSWORD} is set too, the user name field will be set to this value.
|
||||
* When set, and {@link #EXTRA_URL} is not set, the email address field will be set to this value.
|
||||
*/
|
||||
@JvmField
|
||||
val EXTRA_USERNAME = "username"
|
||||
const val EXTRA_USERNAME = "username"
|
||||
|
||||
/**
|
||||
* When set, the password field will be set to this value.
|
||||
*/
|
||||
@JvmField
|
||||
val EXTRA_PASSWORD = "password"
|
||||
const val EXTRA_PASSWORD = "password"
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +48,7 @@ class LoginActivity: AppCompatActivity() {
|
||||
|
||||
if (savedInstanceState == null)
|
||||
// first call, add first login fragment
|
||||
fragmentManager.beginTransaction()
|
||||
supportFragmentManager.beginTransaction()
|
||||
.replace(android.R.id.content, DefaultLoginCredentialsFragment())
|
||||
.commit()
|
||||
}
|
||||
|
||||
+11
-10
@@ -10,34 +10,35 @@ package at.bitfire.davdroid.ui.setup
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import at.bitfire.davdroid.model.Credentials
|
||||
import java.net.URI
|
||||
|
||||
data class LoginCredentials(
|
||||
data class LoginInfo(
|
||||
val uri: URI,
|
||||
val userName: String,
|
||||
val password: String
|
||||
val credentials: Credentials
|
||||
): Parcelable {
|
||||
|
||||
constructor(uri: URI, userName: String? = null, password: String? = null, certificateAlias: String? = null):
|
||||
this(uri, Credentials(userName, password, certificateAlias))
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeSerializable(uri)
|
||||
dest.writeString(userName)
|
||||
dest.writeString(password)
|
||||
dest.writeSerializable(credentials)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmField
|
||||
val CREATOR = object: Parcelable.Creator<LoginCredentials> {
|
||||
val CREATOR = object: Parcelable.Creator<LoginInfo> {
|
||||
override fun createFromParcel(source: Parcel) =
|
||||
LoginCredentials(
|
||||
LoginInfo(
|
||||
source.readSerializable() as URI,
|
||||
source.readString(),
|
||||
source.readString()
|
||||
source.readSerializable() as Credentials
|
||||
)
|
||||
|
||||
override fun newArray(size: Int) = arrayOfNulls<LoginCredentials>(size)
|
||||
override fun newArray(size: Int) = arrayOfNulls<LoginInfo>(size)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright © Ricki Hirner (bitfire web engineering).
|
||||
* All rights reserved. This program and the accompanying materials
|
||||
* are made available under the terms of the GNU Public License v3.0
|
||||
* which accompanies this distribution, and is available at
|
||||
* http://www.gnu.org/licenses/gpl.html
|
||||
*/
|
||||
|
||||
package at.bitfire.davdroid.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.CompoundButton
|
||||
import android.widget.LinearLayout
|
||||
import at.bitfire.davdroid.R
|
||||
import kotlinx.android.synthetic.main.edit_password.view.*
|
||||
|
||||
class EditPassword(
|
||||
context: Context,
|
||||
attrs: AttributeSet?
|
||||
): LinearLayout(context, attrs) {
|
||||
val NS_ANDROID = "http://schemas.android.com/apk/res/android";
|
||||
|
||||
constructor(context: Context): this(context, null)
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.edit_password, this)
|
||||
|
||||
attrs?.let {
|
||||
password.setHint(it.getAttributeResourceValue(NS_ANDROID, "hint", 0))
|
||||
password.setText(it.getAttributeValue(NS_ANDROID, "text"))
|
||||
}
|
||||
|
||||
show_password.setOnCheckedChangeListener({ _: CompoundButton, isChecked: Boolean ->
|
||||
var inputType = password.inputType and EditorInfo.TYPE_MASK_VARIATION.inv()
|
||||
inputType = inputType or if (isChecked) EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD else EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
|
||||
password.inputType = inputType
|
||||
})
|
||||
}
|
||||
|
||||
fun getText() = password.text!!
|
||||
fun setText(text: CharSequence?) = password.setText(text)
|
||||
|
||||
fun setError(error: CharSequence?) {
|
||||
password.error = error
|
||||
}
|
||||
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class MaximizedListView(
|
||||
else {
|
||||
adapter?.let { listAdapter ->
|
||||
val widthSpec = View.MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY)
|
||||
for (i in 0 .. listAdapter.count - 1) {
|
||||
for (i in 0 until listAdapter.count) {
|
||||
val listItem = listAdapter.getView(i, null, this)
|
||||
listItem.measure(widthSpec, View.MeasureSpec.UNSPECIFIED)
|
||||
height += listItem.measuredHeight
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_checked="true"
|
||||
android:drawable="@drawable/ic_visibility_dark" />
|
||||
<item android:state_checked="false"
|
||||
android:drawable="@drawable/ic_visibility_off_dark" />
|
||||
</selector>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
@@ -10,7 +10,6 @@
|
||||
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:fitsSystemWindows="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
@@ -47,7 +47,6 @@
|
||||
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:indeterminate="true"/>
|
||||
|
||||
<at.bitfire.davdroid.ui.widget.MaximizedListView
|
||||
@@ -87,7 +86,6 @@
|
||||
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
android:indeterminate="true"/>
|
||||
|
||||
<at.bitfire.davdroid.ui.widget.MaximizedListView
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
@@ -14,7 +14,6 @@
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:openDrawer="start">
|
||||
|
||||
<include layout="@layout/accounts_content"/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2015 Ricki Hirner (bitfire web engineering).
|
||||
~ Copyright © Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_margin="@dimen/activity_margin">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/calendar_permissions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="16dp"
|
||||
tools:ignore="UselessParent">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/TextView.Heading"
|
||||
android:text="@string/permissions_calendar"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permissions_calendar_details"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permissions_calendar_request"
|
||||
android:onClick="requestCalendarPermissions"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/contacts_permissions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="16dp"
|
||||
tools:ignore="UselessParent">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/TextView.Heading"
|
||||
android:text="@string/permissions_contacts"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permissions_contacts_details"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permissions_contacts_request"
|
||||
android:onClick="requestContactsPermissions"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/opentasks_permissions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
tools:ignore="UselessParent">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
style="@style/TextView.Heading"
|
||||
android:text="@string/permissions_opentasks"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permissions_opentasks_details"/>
|
||||
|
||||
<Button
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/permissions_opentasks_request"
|
||||
android:onClick="requestOpenTasksPermissions"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2013 – 2016 Ricki Hirner (bitfire web engineering).
|
||||
~ All rights reserved. This program and the accompanying materials
|
||||
~ are made available under the terms of the GNU Public License v3.0
|
||||
~ which accompanies this distribution, and is available at
|
||||
~ http://www.gnu.org/licenses/gpl.html
|
||||
-->
|
||||
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/password"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:inputType="textPassword"/>
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/show_password"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="4dp"
|
||||
android:button="@drawable/password_eye_button"
|
||||
android:checked="false"/>
|
||||
|
||||
</LinearLayout>
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user