1/*
2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import androidx.build.ProjectLayoutType
18import org.gradle.api.DefaultTask
19import org.gradle.api.file.DirectoryProperty
20import org.gradle.api.provider.Property
21import org.gradle.api.tasks.Copy
22import org.gradle.api.tasks.PathSensitivity
23
24import javax.inject.Inject
25
26/**
27 * Finds the sqlite sources and puts them into the `destinationDirectory`.
28 *
29 * This task is setup differently between AOSP and Github (Playground), hence use the helper
30 * `registerPrepareSqliteSourcesTask` method to create an instance of it.
31 *
32 * On AOSP, the sources are in an external prebuilts repository and simply get copied into the given
33 * [destinationDirectory].
34 * On Github, they are downloaded from SQLite servers and copied into the `destinationDirectory`
35 * from there.
36 *
37 * To ensure each version is consistent, we use the `sqliteVersion` parameter and check the Sqlite
38 * source code for them.
39 */
40abstract class PrepareSqliteSourcesTask extends DefaultTask {
41    // defined in https://github.com/sqlite/sqlite/blob/master/src/sqlite.h.in#L149
42    private static String VERSION_PREFIX = "#define SQLITE_VERSION"
43    private FileSystemOperations fileSystemOperations
44
45    /**
46     * The Sqlite version to prepare
47     */
48    @Input
49    abstract Property<String> getSqliteVersion()
50
51    /**
52     * The target directory where the Sqlite source will be put
53     */
54    @OutputDirectory
55    abstract DirectoryProperty getDestinationDirectory()
56
57    /**
58     * The source directory which includes the original Sqlite amalgamation distribution
59     */
60    @InputDirectory
61    @PathSensitive(PathSensitivity.NONE)
62    abstract DirectoryProperty getSources()
63
64    @Inject
65    PrepareSqliteSourcesTask(FileSystemOperations fileSystemOperations) {
66        this.fileSystemOperations = fileSystemOperations
67        description = "Create a directory containing Sqlite sources."
68        group = "build"
69    }
70
71    @TaskAction
72    void prepareSources() {
73        File originalSqliteSources = sources.asFile.get()
74        validateSqliteVersion(originalSqliteSources)
75        File targetSourceDirectory = destinationDirectory.asFile.get()
76        targetSourceDirectory.deleteDir()
77        targetSourceDirectory.mkdirs()
78
79        fileSystemOperations.copy { CopySpec copySpec ->
80            copySpec.from(originalSqliteSources)
81            copySpec.into(targetSourceDirectory)
82            copySpec.include("sqlite3.c", "sqlite3.h")
83        }
84    }
85
86    /**
87     * Finds the sqlite version definition in the source file and ensures it is the same
88     * version as [sqliteVersion] to ensure they never go out of sync.
89     */
90    private void validateSqliteVersion(File sourceDir) {
91        File headerFile = new File(sourceDir, "sqlite3.h")
92        if (!headerFile.isFile() || !headerFile.canRead()) {
93            throw new IllegalStateException("Cannot find header file at location: ${headerFile}")
94        }
95        String versionLine = headerFile.text.split('\n').find { it.contains(VERSION_PREFIX) }
96        if (versionLine == null) {
97            throw new IllegalStateException("Cannot find the version line in sqlite.")
98        }
99        String strippedVersion = versionLine.takeAfter(VERSION_PREFIX).trim()
100                .takeBetween("\"", "\"")
101        if (strippedVersion != sqliteVersion.get()) {
102            throw new IllegalStateException("""
103                Expected ${sqliteVersion.get()}, found $strippedVersion. Please update the
104                sqliteVersion parameter if this was intentional.
105            """.trim())
106        }
107    }
108}
109
110/**
111 * Downloads the sqlite amalgamation for the given version.
112 * See: https://sqlite.org/amalgamation.html and https://www.sqlite.org/download.html for
113 * details.
114 */
115@CacheableTask
116abstract class DownloadSQLiteAmalgamationTask extends DefaultTask {
117    /**
118     * The Sqlite version to download
119     */
120    @Input
121    abstract Property<String> getReleaseVersion()
122
123    /**
124     * The year which Sqlite version was released. It is necessary because the download
125     * URL includes the year.
126     */
127    @Input
128    abstract Property<Integer> getReleaseYear()
129
130    /**
131     * Target file where the downloaded amalgamation zip file will be written.
132     */
133    @OutputFile
134    abstract RegularFileProperty getDownloadTargetFile()
135
136    DownloadSQLiteAmalgamationTask() {
137        description = "Downloads the Sqlite amalgamation build from sqlite servers"
138        group = "build"
139    }
140
141    @TaskAction
142    void download() {
143        File downloadTarget = downloadTargetFile.asFile.get()
144        downloadTarget.delete()
145        downloadTarget.parentFile.mkdirs()
146        String downloadUrl = buildDownloadUrl(releaseYear.get(), releaseVersion.get())
147        downloadTarget.withOutputStream { outputStream ->
148            new URL(downloadUrl).withInputStream { inputStream ->
149                inputStream.transferTo(outputStream)
150            }
151        }
152    }
153
154    /**
155     * Computes the download URL from the sqlite version and release year inputs.
156     */
157    private static String buildDownloadUrl(int releaseYear, String releaseVersion) {
158        // see https://www.sqlite.org/download.html
159        // The version is encoded so that filenames sort in order of increasing version number
160        // when viewed using "ls".
161        // For version 3.X.Y the filename encoding is 3XXYY00.
162        // For branch version 3.X.Y.Z, the encoding is 3XXYYZZ.
163        def sections = releaseVersion.split('\\.')
164        if (sections.size() < 3) {
165            throw new IllegalArgumentException("Invalid sqlite version $releaseVersion")
166        }
167        int major = sections[0].toInteger()
168        int minor = sections[1].toInteger()
169        int patch = sections[2].toInteger()
170        int branch = sections.size() >= 4 ? sections[3].toInteger() : 0
171        String fileName = String.format("%d%02d%02d%02d.zip", major, minor, patch, branch)
172        return "https://www.sqlite.org/${releaseYear}/sqlite-amalgamation-${fileName}"
173    }
174}
175
176/**
177 * Configuration object for preparing relevant sqlite sources.
178 */
179abstract class Configuration {
180    /**
181     * The Sqlite version to be prepared.
182     */
183    abstract Property<String> getSqliteVersion()
184
185    /**
186     * The release year of the requested Sqlite version.
187     * It is necessary because the download URL for sqlite amalgamation includes the
188     * release year.
189     */
190    abstract Property<Integer> getSqliteReleaseYear()
191
192    /**
193     * The location to put prepared sqlite sources.
194     */
195    abstract DirectoryProperty getDestinationDirectory()
196
197    /**
198     * Set when sqlite is downloaded from prebuilts rather than from Sqlite servers (used in AOSP).
199     */
200    abstract DirectoryProperty getSqlitePrebuiltsDirectory()
201}
202
203/**
204 * Utility method to create an instance of [PrepareSqliteSourcesTask] that is compatible
205 * with both AOSP and GitHub builds.
206 * This is exported into the build script via ext properties.
207 */
208TaskProvider<PrepareSqliteSourcesTask> registerPrepareSqliteSourcesTask(
209        Project project,
210        String name,
211        Action<Configuration> configure
212) {
213    def configuration = project.objects.newInstance(Configuration.class)
214    configure.execute(configuration)
215
216    def distDirectory = project.objects.directoryProperty()
217    if (ProjectLayoutType.isPlayground(project)) {
218        def downloadTaskProvider = project.tasks.register(
219                name.capitalize() + "DownloadAmalgamation",
220                DownloadSQLiteAmalgamationTask
221        ) {
222            it.releaseVersion.set(configuration.sqliteVersion)
223            it.releaseYear.set(configuration.sqliteReleaseYear)
224            it.downloadTargetFile.set(
225                    project.layout.buildDirectory.file("sqlite3/download/amalgamation.zip")
226            )
227        }
228
229        def unzipTaskProvider = project.tasks.register(
230                name.capitalize() + "UnzipAmalgamation",
231                Copy
232        ) {
233            it.from(
234                    project.zipTree(downloadTaskProvider.map { it.downloadTargetFile })
235            )
236            it.into(
237                    project.layout.buildDirectory.dir("sqlite3/download/unzipped")
238            )
239            it.eachFile {
240                it.path = it.path.replaceFirst(/sqlite-amalgamation-\d+\//, '')
241            }
242        }
243        distDirectory.set(
244                project.objects.directoryProperty().fileProvider(
245                        unzipTaskProvider.map { it.destinationDir }
246                )
247        )
248    } else {
249        distDirectory.set(configuration.sqlitePrebuiltsDirectory)
250    }
251
252    def prepareSourcesTaskProvider = project.tasks.register(
253            name,
254            PrepareSqliteSourcesTask
255    ) {
256        it.sources.set(distDirectory)
257        it.sqliteVersion.set(configuration.sqliteVersion)
258        it.destinationDirectory.set(
259                project.layout.buildDirectory.dir("sqlite/selected-sources")
260        )
261    }
262    return prepareSourcesTaskProvider
263}
264
265// export a function to register the task
266ext.registerPrepareSqliteSourcesTask = this.&registerPrepareSqliteSourcesTask
267