diff options
author | Andrei Popescu <andreip@google.com> | 2009-07-27 12:01:59 +0100 |
---|---|---|
committer | Andrei Popescu <andreip@google.com> | 2009-07-29 11:08:24 +0100 |
commit | 79e82b7ba72a7278911edf0dd7b03c65c4ec0e9d (patch) | |
tree | 763bde3a478037641352fde365e0829cf14eea13 /src | |
parent | 186e593776c396bde3d720addd6f6842fe880a7c (diff) | |
download | packages_apps_Browser-79e82b7ba72a7278911edf0dd7b03c65c4ec0e9d.zip packages_apps_Browser-79e82b7ba72a7278911edf0dd7b03c65c4ec0e9d.tar.gz packages_apps_Browser-79e82b7ba72a7278911edf0dd7b03c65c4ec0e9d.tar.bz2 |
Refactor the WebStorage size management:
- Abandon the Quota UI: it does not make sense to ask the users to decide individual database quota increases. It is unlikely anyone will be able to make a meaningul decision.
- Introduce a global limit for all WebStorage content. This is shared between Database and AppCache.
- Make the quota increase decision automatic
- Treat out-of-space situations by creaying a system notification (TODO).
Diffstat (limited to 'src')
-rw-r--r-- | src/com/android/browser/BrowserActivity.java | 53 | ||||
-rw-r--r-- | src/com/android/browser/BrowserSettings.java | 41 | ||||
-rw-r--r-- | src/com/android/browser/PermissionDialog.java | 175 | ||||
-rw-r--r-- | src/com/android/browser/WebStorageSizeManager.java | 282 |
4 files changed, 314 insertions, 237 deletions
diff --git a/src/com/android/browser/BrowserActivity.java b/src/com/android/browser/BrowserActivity.java index d148c0a..7f40494 100644 --- a/src/com/android/browser/BrowserActivity.java +++ b/src/com/android/browser/BrowserActivity.java @@ -165,8 +165,6 @@ public class BrowserActivity extends Activity private SensorManager mSensorManager = null; - private WebStorage.QuotaUpdater mWebStorageQuotaUpdater = null; - // These are single-character shortcuts for searching popular sources. private static final int SHORTCUT_INVALID = 0; private static final int SHORTCUT_GOOGLE_SEARCH = 1; @@ -3850,36 +3848,38 @@ public class BrowserActivity extends Activity } /** - * The origin has exceeded it's database quota. + * The origin has exceeded its database quota. * @param url the URL that exceeded the quota * @param databaseIdentifier the identifier of the database on * which the transaction that caused the quota overflow was run * @param currentQuota the current quota for the origin. + * @param totalUsedQuota is the sum of all origins' quota. * @param quotaUpdater The callback to run when a decision to allow or * deny quota has been made. Don't forget to call this! */ @Override public void onExceededDatabaseQuota(String url, - String databaseIdentifier, long currentQuota, + String databaseIdentifier, long currentQuota, long totalUsedQuota, WebStorage.QuotaUpdater quotaUpdater) { - if(LOGV_ENABLED) { - Log.v(LOGTAG, - "BrowserActivity received onExceededDatabaseQuota for " - + url + - ":" - + databaseIdentifier + - "(current quota: " - + currentQuota + - ")"); - } - mWebStorageQuotaUpdater = quotaUpdater; - String DIALOG_PACKAGE = "com.android.browser"; - String DIALOG_CLASS = DIALOG_PACKAGE + ".PermissionDialog"; - Intent intent = new Intent(); - intent.setClassName(DIALOG_PACKAGE, DIALOG_CLASS); - intent.putExtra(PermissionDialog.PARAM_ORIGIN, url); - intent.putExtra(PermissionDialog.PARAM_QUOTA, currentQuota); - startActivityForResult(intent, WEBSTORAGE_QUOTA_DIALOG); + mSettings.getWebStorageSizeManager().onExceededDatabaseQuota( + url, databaseIdentifier, currentQuota, totalUsedQuota, + quotaUpdater); + } + + /** + * The Application Cache has exceeded its max size. + * @param spaceNeeded is the amount of disk space that would be needed + * in order for the last appcache operation to succeed. + * @param totalUsedQuota is the sum of all origins' quota. + * @param quotaUpdater A callback to inform the WebCore thread that a new + * app cache size is available. This callback must always be executed at + * some point to ensure that the sleeping WebCore thread is woken up. + */ + @Override + public void onReachedMaxAppCacheSize(long spaceNeeded, + long totalUsedQuota, WebStorage.QuotaUpdater quotaUpdater) { + mSettings.getWebStorageSizeManager().onReachedMaxAppCacheSize( + spaceNeeded, totalUsedQuota, quotaUpdater); } /* Adds a JavaScript error message to the system log. @@ -4616,14 +4616,6 @@ public class BrowserActivity extends Activity } } break; - case WEBSTORAGE_QUOTA_DIALOG: - long currentQuota = 0; - if (resultCode == RESULT_OK && intent != null) { - currentQuota = intent.getLongExtra( - PermissionDialog.PARAM_QUOTA, currentQuota); - } - mWebStorageQuotaUpdater.updateQuota(currentQuota); - break; default: break; } @@ -5169,7 +5161,6 @@ public class BrowserActivity extends Activity final static int COMBO_PAGE = 1; final static int DOWNLOAD_PAGE = 2; final static int PREFERENCES_PAGE = 3; - final static int WEBSTORAGE_QUOTA_DIALOG = 4; // the frenquency of checking whether system memory is low final static int CHECK_MEMORY_INTERVAL = 30000; // 30 seconds diff --git a/src/com/android/browser/BrowserSettings.java b/src/com/android/browser/BrowserSettings.java index d2371cd..3074d2b 100644 --- a/src/com/android/browser/BrowserSettings.java +++ b/src/com/android/browser/BrowserSettings.java @@ -25,7 +25,6 @@ import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; import android.preference.PreferenceActivity; import android.preference.PreferenceScreen; -import android.os.StatFs; import android.webkit.CookieManager; import android.webkit.WebView; import android.webkit.WebViewDatabase; @@ -79,6 +78,7 @@ class BrowserSettings extends Observable { private boolean appCacheEnabled = true; private String appCachePath; // default value set in loadFromDb(). private long appCacheMaxSize = Long.MAX_VALUE; + private WebStorageSizeManager webStorageSizeManager; private boolean domStorageEnabled = true; private String jsFlags = ""; @@ -238,7 +238,9 @@ class BrowserSettings extends Observable { // Set the default value for the Application Caches path. appCachePath = ctx.getDir("appcache", 0).getPath(); // Determine the maximum size of the application cache. - appCacheMaxSize = getAppCacheMaxSize(); + webStorageSizeManager = WebStorageSizeManager.getInstance(appCachePath, + ctx); + appCacheMaxSize = webStorageSizeManager.getAppCacheMaxSize(); // Set the default value for the Database path. databasePath = ctx.getDir("databases", 0).getPath(); @@ -363,6 +365,10 @@ class BrowserSettings extends Observable { return jsFlags; } + public WebStorageSizeManager getWebStorageSizeManager() { + return webStorageSizeManager; + } + public void setHomePage(Context context, String url) { Editor ed = PreferenceManager. getDefaultSharedPreferences(context).edit(); @@ -532,6 +538,8 @@ class BrowserSettings extends Observable { true); // reset homeUrl setHomePage(ctx, getFactoryResetHomeUrl(ctx)); + // reset appcache max size + appCacheMaxSize = webStorageSizeManager.getAppCacheMaxSize(); } private String getFactoryResetHomeUrl(Context context) { @@ -543,35 +551,6 @@ class BrowserSettings extends Observable { return url; } - private long getAppCacheMaxSize() { - StatFs dataPartition = new StatFs(appCachePath); - long freeSpace = dataPartition.getAvailableBlocks() - * dataPartition.getBlockSize(); - long fileSystemSize = dataPartition.getBlockCount() - * dataPartition.getBlockSize(); - return calculateAppCacheMaxSize(fileSystemSize, freeSpace); - } - - /*package*/ static long calculateAppCacheMaxSize(long fileSystemSizeBytes, - long freeSpaceBytes) { - if (fileSystemSizeBytes <= 0 - || freeSpaceBytes <= 0 - || freeSpaceBytes > fileSystemSizeBytes) { - return 0; - } - - long fileSystemSizeRatio = - 4 << ((int) Math.floor(Math.log10(fileSystemSizeBytes / (1024 * 1024)))); - long maxSizeBytes = (long) Math.min(Math.floor(fileSystemSizeBytes / fileSystemSizeRatio), - Math.floor(freeSpaceBytes / 4)); - // Round maxSizeBytes up to a multiple of 512KB (except when freeSpaceBytes < 1MB). - long maxSizeStepBytes = 512 * 1024; - if (freeSpaceBytes < maxSizeStepBytes * 2) { - return 0; - } - return (maxSizeStepBytes * ((maxSizeBytes / maxSizeStepBytes) + 1)); - } - // Private constructor that does nothing. private BrowserSettings() { } diff --git a/src/com/android/browser/PermissionDialog.java b/src/com/android/browser/PermissionDialog.java deleted file mode 100644 index b71261a..0000000 --- a/src/com/android/browser/PermissionDialog.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (C) 2009 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.browser; - -import android.app.Activity; -import android.app.Dialog; -import android.content.Intent; -import android.os.Bundle; -import android.util.Log; -import android.view.Gravity; -import android.view.KeyEvent; -import android.view.View; -import android.view.Window; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -/** - * Permission dialog for HTML5 - * @hide - */ -public class PermissionDialog extends Activity { - - private static final String TAG = "PermissionDialog"; - public static final String PARAM_ORIGIN = "origin"; - public static final String PARAM_QUOTA = "quota"; - - private String mWebStorageOrigin; - private long mWebStorageQuota = 0; - private int mNotification = 0; - - @Override - public void onCreate(Bundle icicle) { - super.onCreate(icicle); - getParameters(); - setupDialog(); - } - - private void getParameters() { - Intent intent = getIntent(); - mWebStorageOrigin = intent.getStringExtra(PARAM_ORIGIN); - mWebStorageQuota = intent.getLongExtra(PARAM_QUOTA, 0); - } - - private void setupDialog() { - requestWindowFeature(Window.FEATURE_NO_TITLE); - setContentView(R.layout.permission_dialog); - - setIcon(R.id.icon, android.R.drawable.ic_popup_disk_full); - setText(R.id.dialog_title, R.string.query_storage_quota_prompt); - setText(R.id.dialog_message, R.string.query_storage_quota_message); - setCharSequence(R.id.origin, mWebStorageOrigin); - - setupButton(R.id.button_allow, R.string.permission_button_allow, - new View.OnClickListener() { - public void onClick(View v) { allow(); } - }); - setupButton(R.id.button_alwaysdeny, R.string.permission_button_alwaysdeny, - new View.OnClickListener() { - public void onClick(View v) { alwaysdeny(); } - }); - setupButton(R.id.button_deny, R.string.permission_button_deny, - new View.OnClickListener() { - public void onClick(View v) { deny(); } - }); - } - - private void setText(int viewID, int stringID) { - setCharSequence(viewID, getString(stringID)); - } - - private void setCharSequence(int viewID, CharSequence string) { - View view = findViewById(viewID); - if (view == null) { - return; - } - view.setVisibility(View.VISIBLE); - TextView textView = (TextView) view; - textView.setText(string); - } - - private void setIcon(int viewID, int imageID) { - View view = findViewById(viewID); - if (view == null) { - return; - } - view.setVisibility(View.VISIBLE); - ImageView icon = (ImageView) view; - icon.setImageResource(imageID); - } - - private void setupButton(int viewID, int stringID, - View.OnClickListener listener) { - View view = findViewById(viewID); - if (view == null) { - return; - } - setText(viewID, stringID); - view.setOnClickListener(listener); - } - - private void useNextQuota() { - CharSequence[] values = getResources().getTextArray( - R.array.webstorage_quota_entries_values); - for (int i=0; i<values.length; i++) { - long value = Long.parseLong(values[i].toString()); - value *= (1024 * 1024); // the string array is expressed in MB - if (value > mWebStorageQuota) { - mWebStorageQuota = value; - break; - } - } - } - - private void allow() { - // If somehow there is no "next quota" in the ladder, - // we'll add 1MB anyway. - mWebStorageQuota += 1024*1024; - useNextQuota(); - mNotification = R.string.webstorage_notification; - closeDialog(); - } - - private void alwaysdeny() { - // Setting the quota to 0 will prevent any new data to be - // added, but the existing data will not be deleted. - mWebStorageQuota = 0; - mNotification = R.string.webstorage_notification; - closeDialog(); - } - - private void deny() { - closeDialog(); - } - - private void closeDialog() { - Intent intent = new Intent(); - intent.putExtra(PARAM_QUOTA, mWebStorageQuota); - setResult(RESULT_OK, intent); - showToast(); - finish(); - } - - private void showToast() { - if (mNotification != 0) { - Toast toast = Toast.makeText(this, mNotification, Toast.LENGTH_LONG); - toast.setGravity(Gravity.BOTTOM, 0, 0); - toast.show(); - } - } - - public boolean dispatchKeyEvent(KeyEvent event) { - if ((event.getKeyCode() == KeyEvent.KEYCODE_BACK) - && (event.getAction() == KeyEvent.ACTION_DOWN)) { - closeDialog(); - return true; // event consumed - } - return super.dispatchKeyEvent(event); - } - -} diff --git a/src/com/android/browser/WebStorageSizeManager.java b/src/com/android/browser/WebStorageSizeManager.java new file mode 100644 index 0000000..e524f4c --- /dev/null +++ b/src/com/android/browser/WebStorageSizeManager.java @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.browser; + +import android.content.Context; +import android.os.StatFs; +import android.util.Log; +import android.webkit.WebStorage; + +import java.io.File; +import java.util.Set; + + +/** + * Package level class for managing the disk size consumed by the WebDatabase + * and ApplicationCaches APIs (henceforth called Web storage). + * + * Currently, the situation on the WebKit side is as follows: + * - WebDatabase enforces a quota for each origin. + * - Session/LocalStorage do not enforce any disk limits. + * - ApplicationCaches enforces a maximum size for all origins. + * + * The WebStorageSizeManager maintains a global limit for the disk space + * consumed by the WebDatabase and ApplicationCaches. As soon as WebKit will + * have a limit for Session/LocalStorage, this class will manage the space used + * by those APIs as well. + * + * The global limit is computed as a function of the size of the partition where + * these APIs store their data (they must store it on the same partition for + * this to work) and the size of the available space on that partition. + * The global limit is not subject to user configuration but we do provide + * a debug-only setting. + * TODO(andreip): implement the debug setting. + * + * The size of the disk space used for Web storage is initially divided between + * WebDatabase and ApplicationCaches as follows: + * + * 75% for WebDatabase + * 25% for ApplicationCaches + * + * When an origin's database usage reaches its current quota, WebKit invokes + * the following callback function: + * - exceededDatabaseQuota(Frame* frame, const String& database_name); + * Note that the default quota for a new origin is 0, so we will receive the + * 'exceededDatabaseQuota' callback before a new origin gets the chance to + * create its first database. + * + * When the total ApplicationCaches usage reaches its current quota, WebKit + * invokes the following callback function: + * - void reachedMaxAppCacheSize(int64_t spaceNeeded); + * + * The WebStorageSizeManager's main job is to respond to the above two callbacks + * by inspecting the amount of unused Web storage quota (i.e. global limit - + * sum of all other origins' quota) and deciding if a quota increase for the + * out-of-space origin is allowed or not. + * + * The default quota for an origin is min(ORIGIN_DEFAULT_QUOTA, unused_quota). + * Quota increases are done in steps, where the increase step is + * min(QUOTA_INCREASE_STEP, unused_quota). + * + * This approach has the drawback that space may remain unused if there + * are many websites that store a lot less content than ORIGIN_DEFAULT_QUOTA. + * We deal with this by picking a value for ORIGIN_DEFAULT_QUOTA that is smaller + * than what the HTML 5 spec recommends. At the same time, picking a very small + * value for ORIGIN_DEFAULT_QUOTA may create performance problems since it's + * more likely for origins to have to rollback and restart transactions as a + * result of reaching the quota more often. + * + * When all the Web storage space is used, the WebStorageSizeManager creates + * a system notification that will guide the user to the WebSettings UI. There, + * the user can free some of the Web storage space by deleting all the data used + * by an origin. + * TODO(andreip): implement the notification. + */ +class WebStorageSizeManager { + // Logging flags. + private final static boolean LOGV_ENABLED = com.android.browser.Browser.LOGV_ENABLED; + private final static boolean LOGD_ENABLED = com.android.browser.Browser.LOGD_ENABLED; + private final static String LOGTAG = "browser"; + // The default quota value for an origin. + private final static long ORIGIN_DEFAULT_QUOTA = 4 * 1024 * 1024; // 4MB + // The default value for quota increases. + private final static long QUOTA_INCREASE_STEP = 2 * 1024 * 1024; // 2MB + // The name of the application cache file. Keep in sync with + // WebCore/loader/appcache/ApplicationCacheStorage.cpp + private final static String APPCACHE_FILE = "ApplicationCache.db"; + // The WebStorageSizeManager singleton. + private static WebStorageSizeManager mManager; + // The application context. + private Context mContext; + // The global Web storage limit. + private long mGlobalLimit; + // The maximum size of the application cache file. + private long mAppCacheMaxSize; + + /** + * Factory method. + * @param path is a path on the partition where the app cache data is kept. + * @param ctx is the browser application context. + * @param storage is the WebStorage singleton. + * + */ + public static WebStorageSizeManager getInstance(String appCachePath, + Context ctx) { + if (mManager == null) { + mManager = new WebStorageSizeManager(appCachePath, ctx); + } + return mManager; + } + + /** + * Returns the maximum size of the application cache. + */ + public long getAppCacheMaxSize() { + return mAppCacheMaxSize; + } + + /** + * The origin has exceeded its database quota. + * @param url the URL that exceeded the quota + * @param databaseIdentifier the identifier of the database on + * which the transaction that caused the quota overflow was run + * @param currentQuota the current quota for the origin. + * @param totalUsedQuota is the sum of all origins' quota. + * @param quotaUpdater The callback to run when a decision to allow or + * deny quota has been made. Don't forget to call this! + */ + public void onExceededDatabaseQuota(String url, + String databaseIdentifier, long currentQuota, long totalUsedQuota, + WebStorage.QuotaUpdater quotaUpdater) { + if(LOGV_ENABLED) { + Log.v(LOGTAG, + "Received onExceededDatabaseQuota for " + + url + + ":" + + databaseIdentifier + + "(current quota: " + + currentQuota + + ")"); + } + long totalUnusedQuota = mGlobalLimit - totalUsedQuota - mAppCacheMaxSize; + + if (totalUnusedQuota < QUOTA_INCREASE_STEP) { + // There definitely isn't any more space. Fire notifications + // and exit. + scheduleOutOfSpaceNotification(); + quotaUpdater.updateQuota(currentQuota); + if(LOGV_ENABLED) { + Log.v(LOGTAG, "onExceededDatabaseQuota: out of space."); + } + return; + } + // We have enough space inside mGlobalLimit. + long newOriginQuota = currentQuota; + if (newOriginQuota == 0) { + // This is a new origin. It wants an initial quota. It is guaranteed + // to get at least ORIGIN_INCREASE_STEP bytes. + newOriginQuota = + Math.min(ORIGIN_DEFAULT_QUOTA, totalUnusedQuota); + } else { + // This is an origin we have seen before. It wants a quota + // increase. + newOriginQuota += + Math.min(QUOTA_INCREASE_STEP, totalUnusedQuota); + } + quotaUpdater.updateQuota(newOriginQuota); + + if(LOGV_ENABLED) { + Log.v(LOGTAG, "onExceededDatabaseQuota set new quota to " + + newOriginQuota); + } + } + + /** + * The Application Cache has exceeded its max size. + * @param spaceNeeded is the amount of disk space that would be needed + * in order for the last appcache operation to succeed. + * @param totalUsedQuota is the sum of all origins' quota. + * @param quotaUpdater A callback to inform the WebCore thread that a new + * app cache size is available. This callback must always be executed at + * some point to ensure that the sleeping WebCore thread is woken up. + */ + public void onReachedMaxAppCacheSize(long spaceNeeded, long totalUsedQuota, + WebStorage.QuotaUpdater quotaUpdater) { + if(LOGV_ENABLED) { + Log.v(LOGTAG, "Received onReachedMaxAppCacheSize with spaceNeeded " + + spaceNeeded + " bytes."); + } + + long totalUnusedQuota = mGlobalLimit - totalUsedQuota - mAppCacheMaxSize; + + if (totalUnusedQuota < spaceNeeded) { + // There definitely isn't any more space. Fire notifications + // and exit. + scheduleOutOfSpaceNotification(); + quotaUpdater.updateQuota(0); + if(LOGV_ENABLED) { + Log.v(LOGTAG, "onReachedMaxAppCacheSize: out of space."); + } + return; + } + // There is enough space to accommodate spaceNeeded bytes. + mAppCacheMaxSize += spaceNeeded; + quotaUpdater.updateQuota(mAppCacheMaxSize); + + if(LOGV_ENABLED) { + Log.v(LOGTAG, "onReachedMaxAppCacheSize set new max size to " + + mAppCacheMaxSize); + } + } + + // Computes the global limit as a function of the size of the data + // partition and the amount of free space on that partition. + private long getGlobalLimit(String path) { + StatFs dataPartition = new StatFs(path); + long freeSpace = dataPartition.getAvailableBlocks() + * dataPartition.getBlockSize(); + long fileSystemSize = dataPartition.getBlockCount() + * dataPartition.getBlockSize(); + return calculateGlobalLimit(fileSystemSize, freeSpace); + } + + // Returns the current size (in bytes) of the application cache file. + private long getCurrentAppCacheSize(String path) { + File file = new File(path + File.separator + APPCACHE_FILE); + return file.length(); + } + + /*package*/ static long calculateGlobalLimit(long fileSystemSizeBytes, + long freeSpaceBytes) { + if (fileSystemSizeBytes <= 0 + || freeSpaceBytes <= 0 + || freeSpaceBytes > fileSystemSizeBytes) { + return 0; + } + + long fileSystemSizeRatio = + 2 << ((int) Math.floor(Math.log10( + fileSystemSizeBytes / (1024 * 1024)))); + long maxSizeBytes = (long) Math.min(Math.floor( + fileSystemSizeBytes / fileSystemSizeRatio), + Math.floor(freeSpaceBytes / 2)); + // Round maxSizeBytes up to a multiple of 1024KB (but only if + // maxSizeBytes > 1MB). + long maxSizeStepBytes = 1024 * 1024; + if (maxSizeBytes < maxSizeStepBytes) { + return 0; + } + long roundingExtra = maxSizeBytes % maxSizeStepBytes == 0 ? 0 : 1; + return (maxSizeStepBytes + * ((maxSizeBytes / maxSizeStepBytes) + roundingExtra)); + } + + // Schedules a system notification that takes the user to the WebSettings + // activity when clicked. + private void scheduleOutOfSpaceNotification() { + // TODO(andreip): implement. + } + // Private ctor. + private WebStorageSizeManager(String appCachePath, Context ctx) { + mContext = ctx; + mGlobalLimit = getGlobalLimit(appCachePath); + // The initial max size of the app cache is either 25% of the global + // limit or the current size of the app cache file, whichever is bigger. + mAppCacheMaxSize = Math.max(mGlobalLimit / 4, + getCurrentAppCacheSize(appCachePath)); + } +}
\ No newline at end of file |