diff --git a/.github/scripts/download-universal-apk.py b/.github/scripts/download-universal-apk.py new file mode 100644 index 0000000000..b3d0c059e6 --- /dev/null +++ b/.github/scripts/download-universal-apk.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Download the Play-signed universal APK for a given version code from Google Play. + +Uses the Android Publisher API generatedApks resource. Google Play generates APKs +from an uploaded AAB asynchronously, so this script retries until the APK is ready +or MAX_WAIT_SECONDS is exceeded. + +Required environment variables: + GOOGLE_API_KEY_JSON Service account JSON (as a string, not a file path) + VERSION_CODE Integer Android version code + PACKAGE_NAME Android package name (default: com.simprints.id) + OUTPUT_APK Output file path (default: universal.apk) + MAX_WAIT_SECONDS Maximum seconds to wait for Play to finish processing (default: 600) +""" + +import json +import os +import sys +import time + +from google.oauth2.service_account import Credentials +from googleapiclient.discovery import build +from googleapiclient.http import MediaIoBaseDownload + +RETRY_INTERVAL_SECONDS = 30 + + +def build_service(service_account_json: str): + creds = Credentials.from_service_account_info( + json.loads(service_account_json), + scopes=["https://www.googleapis.com/auth/androidpublisher"], + ) + return build("androidpublisher", "v3", credentials=creds, cache_discovery=False) + + +def find_universal_apk_download_id(service, package_name: str, version_code: int) -> str | None: + result = service.generatedapks().list( + packageName=package_name, + versionCode=version_code, + ).execute() + + for apk in result.get("generatedApks", []): + universal = apk.get("generatedUniversalApk") + if universal: + return universal["downloadId"] + return None + + +def download_apk(service, package_name: str, version_code: int, download_id: str, output_path: str): + request = service.generatedapks().download_media( + packageName=package_name, + versionCode=version_code, + downloadId=download_id, + ) + with open(output_path, "wb") as f: + downloader = MediaIoBaseDownload(f, request) + done = False + while not done: + status, done = downloader.next_chunk() + if status: + print(f" {int(status.progress() * 100)}%", flush=True) + + size_mb = os.path.getsize(output_path) / 1_048_576 + print(f"Saved to {output_path} ({size_mb:.1f} MB)") + + +def main(): + service_account_json = os.environ["GOOGLE_API_KEY_JSON"] + package_name = os.environ.get("PACKAGE_NAME", "com.simprints.id") + version_code = int(os.environ["VERSION_CODE"]) + output_path = os.environ.get("OUTPUT_APK", "universal.apk") + max_wait = int(os.environ.get("MAX_WAIT_SECONDS", "600")) + + print(f"Looking for universal APK: package={package_name} versionCode={version_code}") + + service = build_service(service_account_json) + deadline = time.monotonic() + max_wait + attempt = 0 + + while True: + attempt += 1 + try: + download_id = find_universal_apk_download_id(service, package_name, version_code) + if download_id: + print(f"Universal APK ready (attempt {attempt}): downloadId={download_id}") + break + print(f"Attempt {attempt}: APKs not generated yet by Play Store.") + except Exception as exc: + print(f"Attempt {attempt}: API error — {exc}") + + remaining = deadline - time.monotonic() + if remaining <= 0: + print(f"ERROR: Universal APK not available after {max_wait}s", file=sys.stderr) + sys.exit(1) + + wait = min(RETRY_INTERVAL_SECONDS, remaining) + print(f"Retrying in {int(wait)}s... ({int(remaining)}s remaining)") + time.sleep(wait) + + print("Downloading...") + download_apk(service, package_name, version_code, download_id, output_path) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/test-generate-version.sh b/.github/scripts/test-generate-version.sh index dde14864cd..7cf1aeb64c 100755 --- a/.github/scripts/test-generate-version.sh +++ b/.github/scripts/test-generate-version.sh @@ -166,6 +166,24 @@ assert_success "run_number at 999" \ "260200999" "999.1" "2026.2.0+dev.999" \ "2026.2.0" 1 999 "dev" +# run_number at 9999 — last 4-digit run number +# 2026.2.0, run 9999 → 260_200_000 + 9_999 = 260_209_999 +assert_success "run_number at 9999 (last 4-digit)" \ + "260209999" "9999.1" "2026.2.0+dev.9999" \ + "2026.2.0" 1 9999 "dev" + +# run_number at 10000 — first 5-digit run number +# 2026.2.0, run 10000 → 260_200_000 + 10_000 = 260_210_000 +assert_success "run_number at 10000 (first 5-digit)" \ + "260210000" "10000.1" "2026.2.0+dev.10000" \ + "2026.2.0" 1 10000 "dev" + +# run_number at 99999 — last 5-digit run number +# 2026.2.0, run 99999 → 260_200_000 + 99_999 = 260_299_999 +assert_success "run_number at 99999 (last 5-digit)" \ + "260299999" "99999.1" "2026.2.0+dev.99999" \ + "2026.2.0" 1 99999 "dev" + # Patch at boundary 99: 2026.1.99, run 1 → 260_000_000 + 100_000 + 99_000 + 1 = 260_199_001 assert_success "patch boundary 99" \ "260199001" "1.1" "2026.1.99+dev.1" \ diff --git a/.github/workflows/pipeline-deploy-to-internal.yml b/.github/workflows/pipeline-deploy-to-internal.yml index 61abcd8c1c..e1a34bb6c8 100644 --- a/.github/workflows/pipeline-deploy-to-internal.yml +++ b/.github/workflows/pipeline-deploy-to-internal.yml @@ -18,16 +18,14 @@ jobs: build-environment: Internal version-name: ${{ needs.get-version.outputs.version-name }} - deploy-internal-build: - uses: ./.github/workflows/reusable-promote-artifact.yml + upload-to-internal-and-release: + uses: ./.github/workflows/reusable-upload-to-internal-and-release.yml secrets: inherit with: - deployment-track: Internal upload-artifact: ${{ needs.build-internal-aab.outputs.build-artifact }} + version-name: ${{ needs.get-version.outputs.version-name }} + version-code: ${{ needs.build-internal-aab.outputs.version-code }} mapping-file: ${{ needs.build-internal-aab.outputs.optional-mapping-file }} needs: + - get-version - build-internal-aab - tag-release: - uses: ./.github/workflows/reusable-update-release-git-tag.yml - needs: deploy-internal-build - secrets: inherit diff --git a/.github/workflows/reusable-upload-to-internal-and-release.yml b/.github/workflows/reusable-upload-to-internal-and-release.yml new file mode 100644 index 0000000000..4ca71129c2 --- /dev/null +++ b/.github/workflows/reusable-upload-to-internal-and-release.yml @@ -0,0 +1,104 @@ +name: "[Reusable] Upload to Internal and Create Draft Release" + +on: + workflow_call: + inputs: + upload-artifact: + description: "Artifact name of the AAB file to upload" + type: string + required: true + version-name: + description: "Version name used for the draft release tag (e.g. 2026.2.0)" + type: string + required: true + version-code: + description: "Android version code for the build (used to download the universal APK from Play)" + type: string + required: true + mapping-file: + description: "Optional artifact name of the ProGuard mapping file" + type: string + required: false + default: "" + +jobs: + upload-and-release: + runs-on: ubuntu-latest + + environment: Internal + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Download AAB artifact + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.upload-artifact }} + + - name: Upload AAB to Google Play Internal track + uses: r0adkll/upload-google-play@eb49699984a39f23558439581660aa6f088acfd6 # v1 + with: + serviceAccountJsonPlainText: ${{ secrets.GOOGLE_API_KEY_JSON }} + packageName: com.simprints.id + releaseFiles: ${{ inputs.upload-artifact }} + track: internal + status: completed + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python dependencies + run: | + pip install --only-binary :all: "google-api-python-client==2.197.0" "google-auth==2.55.0" + - name: Download universal APK from Google Play + id: download-apk + env: + GOOGLE_API_KEY_JSON: ${{ secrets.GOOGLE_API_KEY_JSON }} + VERSION_CODE: ${{ inputs.version-code }} + VERSION_NAME: ${{ inputs.version-name }} + PACKAGE_NAME: com.simprints.id + run: | + APK_NAME="simprints-id-${VERSION_NAME}-universal.apk" + OUTPUT_APK="$APK_NAME" python3 .github/scripts/download-universal-apk.py + echo "apk-name=$APK_NAME" >> "$GITHUB_OUTPUT" + + - name: Download mapping file + if: ${{ inputs.mapping-file != '' }} + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.mapping-file }} + + - name: Create release git tag + uses: ./.github/actions/set-release-git-tag + + - name: Create draft GitHub release with universal APK + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION_NAME: ${{ inputs.version-name }} + MAPPING_FILE: ${{ inputs.mapping-file }} + APK_NAME: ${{ steps.download-apk.outputs.apk-name }} + run: | + TAG="v${VERSION_NAME}" + + # Delete any existing draft release with the same tag to avoid conflicts. + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq '.isDraft' 2>/dev/null | grep -q true; then + gh release delete "$TAG" --repo "$GITHUB_REPOSITORY" --yes + fi + + ASSETS=("$APK_NAME") + if [[ -n "$MAPPING_FILE" && -f "$MAPPING_FILE" ]]; then + ASSETS+=("$MAPPING_FILE") + fi + + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Simprints ID ${VERSION_NAME}" \ + --draft \ + --generate-notes \ + "${ASSETS[@]}" diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt index beda07975f..348064dd87 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialControllerFragment.kt @@ -77,10 +77,6 @@ internal class ExternalCredentialControllerFragment : Fragment(R.layout.fragment private fun initListeners() { requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { when (internalNavController?.currentDestination?.id) { - R.id.externalCredentialSearch -> { - internalNavController?.navigate(R.id.externalCredentialSkip) - } - R.id.externalCredentialSkip, R.id.externalCredentialScanQr, R.id.externalCredentialScanOcr -> { internalNavController?.popBackStack() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt index 0d6701efa0..b026c4d15f 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/controller/ExternalCredentialViewModel.kt @@ -14,6 +14,7 @@ import com.simprints.feature.externalcredential.ExternalCredentialSearchResult import com.simprints.feature.externalcredential.model.ExternalCredentialParams import com.simprints.feature.externalcredential.usecase.ExternalCredentialEventTrackerUseCase import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.experimental import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -29,6 +30,10 @@ internal class ExternalCredentialViewModel @Inject internal constructor( private var isInitialized = false lateinit var params: ExternalCredentialParams private set + var defaultSkipReason: String? = null + private set + var skipReasonsHideHasNumber: Boolean = false + private set val finishEvent: LiveData> get() = _finishEvent private val _finishEvent = MutableLiveData>() @@ -58,8 +63,11 @@ internal class ExternalCredentialViewModel @Inject internal constructor( viewModelScope.launch { val config = configRepository.getProjectConfiguration() + val experimental = config.experimental() val allowedExternalCredentials = config.multifactorId?.allowedExternalCredentials.orEmpty() _externalCredentialTypes.postValue(allowedExternalCredentials) + defaultSkipReason = experimental.mfidDefaultSkipReason + skipReasonsHideHasNumber = experimental.mfidSkipReasonsHideHasNumber } } @@ -81,6 +89,16 @@ internal class ExternalCredentialViewModel @Inject internal constructor( selectedSkipOtherText = otherText?.ifBlank { null } } + fun bypassSkipScreen() { + selectedSkipOtherText = defaultSkipReason + finish( + ExternalCredentialSearchResult.Skipped( + flowType = params.flowType, + skipReason = ExternalCredentialSelectionEvent.SkipReason.OTHER, + ), + ) + } + private fun updateState(state: (ExternalCredentialState) -> ExternalCredentialState) { this.state = state(this.state) } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt index 49887bacf7..6e263db485 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/ExternalCredentialSearchFragment.kt @@ -1,11 +1,13 @@ package com.simprints.feature.externalcredential.screens.search +import android.app.Dialog import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle import android.text.TextWatcher import android.view.View import android.view.inputmethod.InputMethodManager +import androidx.activity.addCallback import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener @@ -30,6 +32,7 @@ import com.simprints.feature.externalcredential.screens.scanocr.usecase.ZoomOnto import com.simprints.feature.externalcredential.screens.search.model.ScannedCredentialResult import com.simprints.feature.externalcredential.screens.search.model.SearchCredentialState import com.simprints.feature.externalcredential.screens.search.model.SearchState +import com.simprints.feature.externalcredential.view.SkipScanConfirmationDialog import com.simprints.infra.logging.LoggingConstants.CrashReportTag.MULTI_FACTOR_ID import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.navigation.navigateSafely @@ -62,6 +65,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext @Inject lateinit var zoomOntoCredentialUseCase: ZoomOntoCredentialUseCase private var credentialTextWatcher: TextWatcher? = null + private var dialog: Dialog? = null override fun onViewCreated( view: View, @@ -70,6 +74,18 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) initObservers() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + if (mainViewModel.defaultSkipReason != null) { + showSkipConfirmationDialog() + } else { + findNavController().navigate(R.id.externalCredentialSkip) + } + } + } + + override fun onDestroyView() { + dismissDialog() + super.onDestroyView() } override fun onPause() { @@ -77,6 +93,11 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext super.onPause() } + private fun dismissDialog() { + dialog?.dismiss() + dialog = null + } + private fun initObservers() { viewModel.stateLiveData.observe(viewLifecycleOwner) { state -> renderCredentialCard(state) @@ -106,11 +127,12 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext credentialEditText.inputType = viewModel.getKeyBoardInputType() credentialEditText.hint = credentialField credentialValue.text = currentEditTextValue - confirmCredentialCheckbox.isVisible = state.searchState != SearchState.Searching + confirmCredentialCheckbox.isVisible = state.isUserConfirmationRequired && state.searchState != SearchState.Searching confirmCredentialCheckbox.text = getString(IDR.string.mfid_confirmation_checkbox_text, credentialField) confirmCredentialCheckbox.isChecked = state.isConfirmed && !state.isEditingCredential confirmCredentialCheckbox.isEnabled = !state.isEditingCredential + iconEditCredential.isVisible = state.isEditAvailable iconEditCredential.setOnClickListener { if (isEditingCredential) { viewModel.confirmCredentialUpdate(updatedCredential = credentialEditText.text.toString().asTokenizableRaw()) @@ -209,7 +231,7 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext val isSearching = state.searchState != SearchState.Searching buttonRecapture.isVisible = isSearching buttonConfirm.isVisible = isSearching - buttonConfirm.isEnabled = state.isConfirmed && !state.isEditingCredential + buttonConfirm.isEnabled = !state.isUserConfirmationRequired || (state.isConfirmed && !state.isEditingCredential) viewModel.getButtonTextResource(state.searchState, state.flowType)?.run(buttonConfirm::setText) buttonConfirm.setOnClickListener { viewModel.finish(state) @@ -259,6 +281,19 @@ internal class ExternalCredentialSearchFragment : Fragment(R.layout.fragment_ext } } + private fun showSkipConfirmationDialog() { + dismissDialog() + dialog = SkipScanConfirmationDialog( + context = requireContext(), + credentialTypes = mainViewModel.externalCredentialTypes.value.orEmpty(), + onConfirm = { + dismissDialog() + mainViewModel.bypassSkipScreen() + }, + onCancel = ::dismissDialog, + ).also { it.show() } + } + private fun hideKeyboard() { requireActivity().hideKeyboard() } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt index 0e20510a76..a446b3815d 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/search/model/SearchCredentialState.kt @@ -3,6 +3,7 @@ package com.simprints.feature.externalcredential.screens.search.model import androidx.annotation.Keep import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import com.simprints.core.domain.common.FlowType +import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.feature.externalcredential.model.CredentialMatch import kotlin.Boolean @@ -16,19 +17,31 @@ internal data class SearchCredentialState( val searchState: SearchState, val isConfirmed: Boolean, val isEditingCredential: Boolean, + val isUserConfirmationRequired: Boolean, + val isEditAvailable: Boolean, ) { companion object { fun buildInitial( scannedCredentialResult: ScannedCredentialResult, flowType: FlowType, - ) = SearchCredentialState( - scannedCredentialResult = scannedCredentialResult, - displayedCredential = scannedCredentialResult.credential, - flowType = flowType, - searchState = SearchState.Searching, - isConfirmed = false, - isEditingCredential = false, - ) + ): SearchCredentialState { + // [MS-1484] Editing and confirming the readout value should only be available for the OCR readouts (i.e.: Ghana NHIS card) + // QR codes are not readable by humans, hence there is no point of asking the user to confirm the readout, nor to edit the readout + val isOcrReadout = when (scannedCredentialResult.credentialType) { + ExternalCredentialType.NHISCard, ExternalCredentialType.GhanaIdCard -> true + ExternalCredentialType.QRCode -> false + } + return SearchCredentialState( + scannedCredentialResult = scannedCredentialResult, + displayedCredential = scannedCredentialResult.credential, + flowType = flowType, + searchState = SearchState.Searching, + isConfirmed = false, + isEditingCredential = false, + isUserConfirmationRequired = isOcrReadout, + isEditAvailable = isOcrReadout, + ) + } } } diff --git a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt index 7e942024e0..31f0040a85 100644 --- a/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt +++ b/feature/external-credential/src/main/java/com/simprints/feature/externalcredential/screens/select/ExternalCredentialSelectFragment.kt @@ -3,16 +3,11 @@ package com.simprints.feature.externalcredential.screens.select import android.app.Dialog import android.os.Bundle import android.view.View -import android.widget.Button -import android.widget.TextView import androidx.activity.addCallback import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog import com.simprints.core.domain.externalcredential.ExternalCredentialType import com.simprints.feature.externalcredential.R import com.simprints.feature.externalcredential.databinding.FragmentExternalCredentialSelectBinding @@ -22,6 +17,7 @@ import com.simprints.feature.externalcredential.screens.controller.ExternalCrede import com.simprints.feature.externalcredential.screens.scanocr.model.OcrDocumentType import com.simprints.feature.externalcredential.screens.scanocr.model.asOcrDocumentType import com.simprints.feature.externalcredential.screens.select.view.ExternalCredentialTypeAdapter +import com.simprints.feature.externalcredential.view.SkipScanConfirmationDialog import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.navigation.navigateSafely @@ -48,9 +44,9 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext observeChanges() } - override fun onDestroy() { + override fun onDestroyView() { dismissDialog() - super.onDestroy() + super.onDestroyView() } private fun dismissDialog() { @@ -60,17 +56,23 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext private fun initListeners(types: List) { binding.skipScanning.setOnClickListener { - displaySkipScanningConfirmationDialog( + dismissDialog() + dialog = SkipScanConfirmationDialog( + context = requireContext(), credentialTypes = types, onConfirm = { dismissDialog() - findNavController().navigateSafely( - this, - ExternalCredentialSelectFragmentDirections.actionExternalCredentialSelectFragmentToExternalCredentialSkip(), - ) + if (mainViewModel.defaultSkipReason != null) { + mainViewModel.bypassSkipScreen() + } else { + findNavController().navigateSafely( + this, + ExternalCredentialSelectFragmentDirections.actionExternalCredentialSelectFragmentToExternalCredentialSkip(), + ) + } }, onCancel = ::dismissDialog, - ) + ).also { it.show() } } requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { binding.skipScanning.performClick() @@ -127,39 +129,6 @@ internal class ExternalCredentialSelectFragment : Fragment(R.layout.fragment_ext ) } - private fun displaySkipScanningConfirmationDialog( - credentialTypes: List, - onConfirm: () -> Unit, - onCancel: () -> Unit, - ) { - dismissDialog() - dialog = BottomSheetDialog(requireContext()).also { - val view = layoutInflater - .inflate(R.layout.dialog_skip_scan_confirm, null) - .also { view -> - val bodyText = view.findViewById(R.id.skipDialogBodyText) - val cancelButton = view.findViewById